From 2efe54e4171e3efeb0bd2f455b3113de9d24d5fc Mon Sep 17 00:00:00 2001 From: HueByte Date: Fri, 20 Feb 2026 17:16:32 +0100 Subject: [PATCH] feat: Implement message encryption and decryption support - Added IMessageEncryptionService and its implementation MessageEncryptionService for handling message encryption. - Updated ChannelsController and ChatService to encrypt messages before storing and sending. - Introduced encryption key retrieval endpoint in ServerController. - Modified EchoHubDbContext to accommodate increased message content and embed JSON lengths for encrypted data. - Created migrations to support encryption-related database changes. - Enhanced FirstRunSetup to ensure encryption key is generated if not present. - Updated appsettings.example.json to include encryption configuration. - Added comprehensive unit tests for encryption service and compatibility tests between client and server encryption. --- docs/articles/encryption.md | 137 +++++++++ docs/changelog/v0.2.4.md | 32 +++ src/Directory.Build.props | 2 +- src/EchoHub.Client/AppOrchestrator.cs | 16 +- src/EchoHub.Client/Services/ApiClient.cs | 10 + .../Services/ClientEncryptionService.cs | 95 +++++++ .../Services/EchoHubConnection.cs | 23 +- .../Contracts/IMessageEncryptionService.cs | 14 + src/EchoHub.Core/DTOs/ServerDtos.cs | 2 + src/EchoHub.Server.Irc/IrcBroadcaster.cs | 8 +- .../Controllers/ChannelsController.cs | 17 +- .../Controllers/ServerController.cs | 15 + src/EchoHub.Server/Data/EchoHubDbContext.cs | 4 +- ...220133627_AddEncryptionSupport.Designer.cs | 264 ++++++++++++++++++ .../20260220133627_AddEncryptionSupport.cs | 22 ++ .../EchoHubDbContextModelSnapshot.cs | 4 +- src/EchoHub.Server/Program.cs | 3 + src/EchoHub.Server/Services/ChatService.cs | 48 +++- .../Services/MessageEncryptionService.cs | 102 +++++++ .../Setup/DataMigrationService.cs | 1 + src/EchoHub.Server/Setup/FirstRunSetup.cs | 24 ++ src/EchoHub.Server/appsettings.example.json | 4 + .../ClientEncryptionServiceTests.cs | 233 ++++++++++++++++ src/EchoHub.Tests/EchoHub.Tests.csproj | 1 + .../EncryptionCompatibilityTests.cs | 208 ++++++++++++++ .../MessageEncryptionServiceTests.cs | 259 +++++++++++++++++ 26 files changed, 1517 insertions(+), 31 deletions(-) create mode 100644 docs/articles/encryption.md create mode 100644 docs/changelog/v0.2.4.md create mode 100644 src/EchoHub.Client/Services/ClientEncryptionService.cs create mode 100644 src/EchoHub.Core/Contracts/IMessageEncryptionService.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260220133627_AddEncryptionSupport.Designer.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260220133627_AddEncryptionSupport.cs create mode 100644 src/EchoHub.Server/Services/MessageEncryptionService.cs create mode 100644 src/EchoHub.Tests/ClientEncryptionServiceTests.cs create mode 100644 src/EchoHub.Tests/EncryptionCompatibilityTests.cs create mode 100644 src/EchoHub.Tests/MessageEncryptionServiceTests.cs diff --git a/docs/articles/encryption.md b/docs/articles/encryption.md new file mode 100644 index 0000000..3bbc08c --- /dev/null +++ b/docs/articles/encryption.md @@ -0,0 +1,137 @@ +# 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 + +```text +TUI Client Server TUI Client + │ │ │ + │ encrypt(plaintext) │ │ + │ ──── $ENC$v1$... ──────────────► │ │ + │ │ decrypt → validate/sanitize │ + │ │ fetch embeds on plaintext │ + │ │ encrypt(plaintext) │ + │ │ ──── $ENC$v1$... ──────────────► │ + │ │ │ decrypt → display + │ │ │ + │ │ (optional) encrypt for DB │ + │ │ store to SQLite │ +``` + +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. +- **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. diff --git a/docs/changelog/v0.2.4.md b/docs/changelog/v0.2.4.md new file mode 100644 index 0000000..6663103 --- /dev/null +++ b/docs/changelog/v0.2.4.md @@ -0,0 +1,32 @@ +# v0.2.4 - E2E Message Encryption + +## Features + +### E2E Message Encryption + +- Application-layer AES-256-GCM encryption for all message content between client and server +- Protects against ISPs, proxies, and any middleman reading chat messages — even if TLS is compromised +- 256-bit encryption key auto-generated on first server startup and saved to `appsettings.json` +- Client fetches the encryption key automatically after login via `GET /api/server/encryption-key` +- Client encrypts messages before sending via SignalR; server decrypts for validation and processing +- Server broadcasts encrypted content to SignalR clients; client decrypts transparently — no user action required +- Optional database encryption at rest via `Encryption:EncryptDatabase` setting (disabled by default) + - When enabled: new messages encrypted before DB storage (existing plaintext messages are not retroactively encrypted) + - When disabled: messages stored as plaintext, no risk of data loss from key rotation + - Reads handle mixed content (encrypted + plaintext) regardless of setting — safe to toggle at any time + - Key rotation is safe: old plaintext stays readable, new messages use the new key +- IRC gateway automatically decrypts messages before forwarding to IRC clients (plaintext over IRC) +- Encrypted content format: `$ENC$v1${nonce}${ciphertext+tag}` (Base64, 12-byte nonce, 16-byte auth tag) +- Server strips `$ENC$` prefix from user-typed messages to prevent format spoofing +- Graceful fallback: if decryption fails, shows `[encrypted message — decryption failed, try re-logging to fetch the latest key]` + +## Infrastructure + +- `IMessageEncryptionService` interface in Core; `MessageEncryptionService` server implementation and `ClientEncryptionService` client implementation +- `EncryptionKeyResponse` DTO and `GET /api/server/encryption-key` endpoint (authenticated, rate-limited) +- `FirstRunSetup.EnsureEncryptionKey()` auto-generates AES-256 key on first server run +- `Encryption:EncryptDatabase` server setting (default `false`) controls whether messages are encrypted at rest +- DB column max lengths increased for encrypted content: `Message.Content` 2000 → 16000, `Message.EmbedJson` 8000 → 32000 +- EF Core migration: `AddEncryptionSupport` +- Encryption test suite: server-side, client-side, and cross-compatibility tests (87 total) +- Documentation article: `docs/articles/encryption.md` diff --git a/src/Directory.Build.props b/src/Directory.Build.props index d137f55..e2dfc80 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 0.2.3 + 0.2.4 true $(NoWarn);CS1591 diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 1c9f65f..40748ac 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -25,6 +25,7 @@ public sealed class AppOrchestrator : IDisposable private EchoHubConnection? _connection; private ApiClient? _apiClient; + private readonly ClientEncryptionService _encryption = new(); private ClientConfig _config; private UserStatus _currentStatus = UserStatus.Online; private string? _currentStatusMessage; @@ -382,6 +383,19 @@ public sealed class AppOrchestrator : IDisposable _currentUsername = loginResponse.Username; + // Fetch encryption key for E2E message encryption + InvokeUI(() => _mainWindow.UpdateStatusBar("Fetching encryption key...")); + try + { + var encryptionKey = await _apiClient.GetEncryptionKeyAsync(); + _encryption.SetKey(encryptionKey); + Log.Information("E2E encryption key established"); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted"); + } + InvokeUI(() => { _mainWindow.SetCurrentUser(loginResponse.DisplayName ?? loginResponse.Username); @@ -391,7 +405,7 @@ public sealed class AppOrchestrator : IDisposable if (_connection is not null) await _connection.DisposeAsync(); - _connection = new EchoHubConnection(result.ServerUrl, _apiClient); + _connection = new EchoHubConnection(result.ServerUrl, _apiClient, _encryption); WireConnectionEvents(_connection); await _connection.ConnectAsync(); diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index d250e2b..825cad0 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -128,6 +128,16 @@ public sealed class ApiClient : IDisposable return info; } + public async Task GetEncryptionKeyAsync() + { + EnsureAuthenticated(); + var response = await AuthenticatedGetAsync("/api/server/encryption-key"); + await EnsureSuccessAsync(response); + var result = await response.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("Server returned empty encryption key response."); + return result.Key; + } + public async Task GetUserProfileAsync(string username) { EnsureAuthenticated(); diff --git a/src/EchoHub.Client/Services/ClientEncryptionService.cs b/src/EchoHub.Client/Services/ClientEncryptionService.cs new file mode 100644 index 0000000..24b281b --- /dev/null +++ b/src/EchoHub.Client/Services/ClientEncryptionService.cs @@ -0,0 +1,95 @@ +using System.Security.Cryptography; +using System.Text; +using EchoHub.Core.Contracts; + +namespace EchoHub.Client.Services; + +/// +/// Client-side encryption service. Uses the same AES-256-GCM format as the server +/// so messages are encrypted end-to-end between client and server. +/// +public sealed class ClientEncryptionService : IMessageEncryptionService +{ + private const string EncryptionPrefix = "$ENC$v1$"; + private const int NonceSizeBytes = 12; + private const int TagSizeBytes = 16; + + private byte[]? _key; + + public bool IsInitialized => _key is not null; + public bool EncryptDatabaseEnabled => false; // Not relevant for client + + /// + /// Initialize with the server's encryption key (fetched after login). + /// + public void SetKey(string base64Key) + { + _key = Convert.FromBase64String(base64Key); + + if (_key.Length != 32) + throw new InvalidOperationException($"Encryption key must be exactly 32 bytes (256-bit). Got {_key.Length} bytes."); + } + + public string Encrypt(string plaintext) + { + if (_key is null) + return plaintext; // Not initialized — pass through + + var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + var nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes); + var ciphertext = new byte[plaintextBytes.Length]; + var tag = new byte[TagSizeBytes]; + + using var aes = new AesGcm(_key, TagSizeBytes); + aes.Encrypt(nonce, plaintextBytes, ciphertext, tag); + + var combined = new byte[ciphertext.Length + tag.Length]; + Buffer.BlockCopy(ciphertext, 0, combined, 0, ciphertext.Length); + Buffer.BlockCopy(tag, 0, combined, ciphertext.Length, tag.Length); + + return $"{EncryptionPrefix}{Convert.ToBase64String(nonce)}${Convert.ToBase64String(combined)}"; + } + + public string Decrypt(string content) + { + if (_key is null || !content.StartsWith(EncryptionPrefix)) + return content; // Not initialized or legacy plaintext + + try + { + var payload = content[EncryptionPrefix.Length..]; + var separatorIndex = payload.IndexOf('$'); + if (separatorIndex < 0) + return content; + + var nonceBase64 = payload[..separatorIndex]; + var combinedBase64 = payload[(separatorIndex + 1)..]; + + var nonce = Convert.FromBase64String(nonceBase64); + var combined = Convert.FromBase64String(combinedBase64); + + if (combined.Length < TagSizeBytes) + return content; + + var ciphertextLength = combined.Length - TagSizeBytes; + var ciphertext = combined.AsSpan(0, ciphertextLength); + var tag = combined.AsSpan(ciphertextLength, TagSizeBytes); + var plaintext = new byte[ciphertextLength]; + + using var aes = new AesGcm(_key, TagSizeBytes); + aes.Decrypt(nonce, ciphertext, tag, plaintext); + + return Encoding.UTF8.GetString(plaintext); + } + catch + { + return "[encrypted message — decryption failed, try re-logging to fetch the latest key]"; + } + } + + public string? EncryptNullable(string? value) + => value is null ? null : Encrypt(value); + + public string? DecryptNullable(string? value) + => value is null ? null : Decrypt(value); +} diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs index 3ee18fd..6e6a17b 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -8,6 +8,7 @@ namespace EchoHub.Client.Services; public sealed class EchoHubConnection : IAsyncDisposable { private readonly HubConnection _connection; + private readonly ClientEncryptionService _encryption; public event Action? OnMessageReceived; public event Action? OnUserJoined; @@ -25,8 +26,9 @@ public sealed class EchoHubConnection : IAsyncDisposable public bool IsConnected => _connection.State == HubConnectionState.Connected; - public EchoHubConnection(string serverUrl, ApiClient apiClient) + public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption) { + _encryption = encryption; var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath; _connection = new HubConnectionBuilder() @@ -63,7 +65,9 @@ public sealed class EchoHubConnection : IAsyncDisposable { _connection.On(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message => { - OnMessageReceived?.Invoke(message); + // Decrypt message content received from server + var decrypted = message with { Content = _encryption.Decrypt(message.Content) }; + OnMessageReceived?.Invoke(decrypted); }); _connection.On(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) => @@ -132,7 +136,8 @@ public sealed class EchoHubConnection : IAsyncDisposable public async Task> JoinChannelAsync(string channelName) { - return await _connection.InvokeAsync>("JoinChannel", channelName); + var messages = await _connection.InvokeAsync>("JoinChannel", channelName); + return DecryptMessages(messages); } public async Task LeaveChannelAsync(string channelName) @@ -142,12 +147,15 @@ public sealed class EchoHubConnection : IAsyncDisposable public async Task SendMessageAsync(string channelName, string content) { - await _connection.InvokeAsync("SendMessage", channelName, content); + // Encrypt content before sending to server + var encrypted = _encryption.Encrypt(content); + await _connection.InvokeAsync("SendMessage", channelName, encrypted); } public async Task> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount) { - return await _connection.InvokeAsync>("GetChannelHistory", channelName, count); + var messages = await _connection.InvokeAsync>("GetChannelHistory", channelName, count); + return DecryptMessages(messages); } public async Task UpdateStatusAsync(UserStatus status, string? statusMessage = null) @@ -160,6 +168,11 @@ public sealed class EchoHubConnection : IAsyncDisposable return await _connection.InvokeAsync>("GetOnlineUsers", channelName); } + private List DecryptMessages(List messages) + { + return messages.Select(m => m with { Content = _encryption.Decrypt(m.Content) }).ToList(); + } + public async ValueTask DisposeAsync() { await _connection.DisposeAsync(); diff --git a/src/EchoHub.Core/Contracts/IMessageEncryptionService.cs b/src/EchoHub.Core/Contracts/IMessageEncryptionService.cs new file mode 100644 index 0000000..5c3f3aa --- /dev/null +++ b/src/EchoHub.Core/Contracts/IMessageEncryptionService.cs @@ -0,0 +1,14 @@ +namespace EchoHub.Core.Contracts; + +public interface IMessageEncryptionService +{ + /// + /// Whether database content should be encrypted at rest (server setting). + /// + bool EncryptDatabaseEnabled { get; } + + string Encrypt(string plaintext); + string Decrypt(string content); + string? EncryptNullable(string? value); + string? DecryptNullable(string? value); +} diff --git a/src/EchoHub.Core/DTOs/ServerDtos.cs b/src/EchoHub.Core/DTOs/ServerDtos.cs index 216a6a8..82f9263 100644 --- a/src/EchoHub.Core/DTOs/ServerDtos.cs +++ b/src/EchoHub.Core/DTOs/ServerDtos.cs @@ -1,3 +1,5 @@ namespace EchoHub.Core.DTOs; public record ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels); + +public record EncryptionKeyResponse(string Key); diff --git a/src/EchoHub.Server.Irc/IrcBroadcaster.cs b/src/EchoHub.Server.Irc/IrcBroadcaster.cs index 40b88a3..058cd79 100644 --- a/src/EchoHub.Server.Irc/IrcBroadcaster.cs +++ b/src/EchoHub.Server.Irc/IrcBroadcaster.cs @@ -6,15 +6,19 @@ namespace EchoHub.Server.Irc; public class IrcBroadcaster : IChatBroadcaster { private readonly IrcGatewayService _gateway; + private readonly IMessageEncryptionService _encryption; - public IrcBroadcaster(IrcGatewayService gateway) + public IrcBroadcaster(IrcGatewayService gateway, IMessageEncryptionService encryption) { _gateway = gateway; + _encryption = encryption; } public async Task SendMessageToChannelAsync(string channelName, MessageDto message) { - var lines = IrcMessageFormatter.FormatMessage(message); + // Decrypt content for IRC clients (they can't handle app-layer encryption) + var decryptedMessage = message with { Content = _encryption.Decrypt(message.Content) }; + var lines = IrcMessageFormatter.FormatMessage(decryptedMessage); foreach (var conn in _gateway.GetConnectionsInChannel(channelName)) { diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 5db6ccf..753da7f 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -23,19 +23,22 @@ public class ChannelsController : ControllerBase private readonly ImageToAsciiService _asciiService; private readonly IHttpClientFactory _httpClientFactory; private readonly IChatService _chatService; + private readonly IMessageEncryptionService _encryption; public ChannelsController( EchoHubDbContext db, FileStorageService fileStorage, ImageToAsciiService asciiService, IHttpClientFactory httpClientFactory, - IChatService chatService) + IChatService chatService, + IMessageEncryptionService encryption) { _db = db; _fileStorage = fileStorage; _asciiService = asciiService; _httpClientFactory = httpClientFactory; _chatService = chatService; + _encryption = encryption; } [HttpGet] @@ -221,11 +224,12 @@ public class ChannelsController : ControllerBase var attachmentUrl = $"/api/files/{fileId}"; var sender = await _db.Users.FindAsync(userId); + var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content; var message = new Message { Id = Guid.NewGuid(), - Content = content, + Content = dbContent, Type = messageType, AttachmentUrl = attachmentUrl, AttachmentFileName = file.FileName, @@ -238,9 +242,10 @@ public class ChannelsController : ControllerBase _db.Messages.Add(message); await _db.SaveChangesAsync(); + // Encrypt for transport — clients decrypt var messageDto = new MessageDto( message.Id, - message.Content, + _encryption.Encrypt(content), message.SenderUsername, sender?.NicknameColor, channelName, @@ -339,11 +344,12 @@ public class ChannelsController : ControllerBase var attachmentUrl = $"/api/files/{fileId}"; var sender = await _db.Users.FindAsync(userId); + var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content; var message = new Message { Id = Guid.NewGuid(), - Content = content, + Content = dbContent, Type = MessageType.Image, AttachmentUrl = attachmentUrl, AttachmentFileName = fileName, @@ -356,9 +362,10 @@ public class ChannelsController : ControllerBase _db.Messages.Add(message); await _db.SaveChangesAsync(); + // Encrypt for transport — clients decrypt var messageDto = new MessageDto( message.Id, - message.Content, + _encryption.Encrypt(content), message.SenderUsername, sender?.NicknameColor, channelName, diff --git a/src/EchoHub.Server/Controllers/ServerController.cs b/src/EchoHub.Server/Controllers/ServerController.cs index 8b47906..23491ca 100644 --- a/src/EchoHub.Server/Controllers/ServerController.cs +++ b/src/EchoHub.Server/Controllers/ServerController.cs @@ -1,6 +1,8 @@ using EchoHub.Core.DTOs; using EchoHub.Server.Data; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace EchoHub.Server.Controllers; @@ -32,4 +34,17 @@ public class ServerController : ControllerBase return Ok(status); } + + [HttpGet("encryption-key")] + [Authorize] + [EnableRateLimiting("auth")] + public IActionResult GetEncryptionKey() + { + var key = _config["Encryption:Key"]; + + if (string.IsNullOrEmpty(key)) + return StatusCode(503, new ErrorResponse("Encryption is not configured on this server.")); + + return Ok(new EncryptionKeyResponse(key)); + } } diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs index 4eff95c..303cda5 100644 --- a/src/EchoHub.Server/Data/EchoHubDbContext.cs +++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs @@ -55,11 +55,11 @@ public class EchoHubDbContext : DbContext { entity.HasKey(m => m.Id); entity.HasIndex(m => m.SentAt); - entity.Property(m => m.Content).IsRequired().HasMaxLength(2000); + entity.Property(m => m.Content).IsRequired().HasMaxLength(16000); // Increased for encrypted content (Base64 overhead) entity.Property(m => m.SenderUsername).IsRequired().HasMaxLength(50); entity.Property(m => m.AttachmentUrl).HasMaxLength(500); entity.Property(m => m.AttachmentFileName).HasMaxLength(255); - entity.Property(m => m.EmbedJson).HasMaxLength(8000); + entity.Property(m => m.EmbedJson).HasMaxLength(32000); // Increased for encrypted embed JSON }); modelBuilder.Entity(entity => diff --git a/src/EchoHub.Server/Data/Migrations/20260220133627_AddEncryptionSupport.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260220133627_AddEncryptionSupport.Designer.cs new file mode 100644 index 0000000..89999b1 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260220133627_AddEncryptionSupport.Designer.cs @@ -0,0 +1,264 @@ +// +using System; +using EchoHub.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + [DbContext(typeof(EchoHubDbContext))] + [Migration("20260220133627_AddEncryptionSupport")] + partial class AddEncryptionSupport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.3"); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("JoinedAt") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "ChannelId"); + + b.HasIndex("ChannelId"); + + b.HasIndex("UserId"); + + b.ToTable("ChannelMemberships"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(16000) + .HasColumnType("TEXT"); + + b.Property("EmbedJson") + .HasMaxLength(32000) + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsMuted") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.HasOne("EchoHub.Core.Models.Channel", null) + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EchoHub.Core.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Message", b => + { + b.HasOne("EchoHub.Core.Models.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.HasOne("EchoHub.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.Channel", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/20260220133627_AddEncryptionSupport.cs b/src/EchoHub.Server/Data/Migrations/20260220133627_AddEncryptionSupport.cs new file mode 100644 index 0000000..d75598c --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260220133627_AddEncryptionSupport.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class AddEncryptionSupport : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs index 66f12ce..715ed37 100644 --- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -88,11 +88,11 @@ namespace EchoHub.Server.Data.Migrations b.Property("Content") .IsRequired() - .HasMaxLength(2000) + .HasMaxLength(16000) .HasColumnType("TEXT"); b.Property("EmbedJson") - .HasMaxLength(8000) + .HasMaxLength(32000) .HasColumnType("TEXT"); b.Property("SenderUserId") diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 4aa6972..0e830a5 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -109,6 +109,9 @@ while (true) builder.Services.AddSingleton(); builder.Services.AddHostedService(); + // ── Encryption ───────────────────────────────────────────────────── + builder.Services.AddSingleton(); + // ── Chat Service + Broadcasters ───────────────────────────────────── builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index d590a65..a75343b 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -16,6 +16,7 @@ public class ChatService : IChatService private readonly PresenceTracker _presenceTracker; private readonly IEnumerable _broadcasters; private readonly LinkEmbedService _embedService; + private readonly IMessageEncryptionService _encryption; private readonly ILogger _logger; public ChatService( @@ -23,12 +24,14 @@ public class ChatService : IChatService PresenceTracker presenceTracker, IEnumerable broadcasters, LinkEmbedService embedService, + IMessageEncryptionService encryption, ILogger logger) { _scopeFactory = scopeFactory; _presenceTracker = presenceTracker; _broadcasters = broadcasters; _embedService = embedService; + _encryption = encryption; _logger = logger; } @@ -142,14 +145,21 @@ public class ChatService : IChatService if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) return "Invalid channel name."; - if (string.IsNullOrWhiteSpace(content)) + // Decrypt content (client sends encrypted; IRC sends plaintext — Decrypt handles both) + var plaintext = _encryption.Decrypt(content); + + // Strip encryption prefix if a user typed it literally (prevents spoofing) + while (plaintext.StartsWith("$ENC$")) + plaintext = plaintext["$ENC$".Length..]; + + if (string.IsNullOrWhiteSpace(plaintext)) return "Message content cannot be empty."; - if (content.Length > HubConstants.MaxMessageLength) + if (plaintext.Length > HubConstants.MaxMessageLength) return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."; - // Sanitize: collapse excessive newlines - content = SanitizeNewlines(content); + // Sanitize on plaintext: collapse excessive newlines + plaintext = SanitizeNewlines(plaintext); using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -175,35 +185,42 @@ public class ChatService : IChatService } } - // Attempt to fetch link embeds for URLs in the message + // Attempt to fetch link embeds for URLs in the plaintext message List? embeds = null; try { - embeds = await _embedService.TryGetEmbedsAsync(content); + embeds = await _embedService.TryGetEmbedsAsync(plaintext); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to fetch link embeds for message in '{Channel}'", channelName); } + // Store in DB — encrypted at rest if enabled, plaintext otherwise + var embedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null; + var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(plaintext) : plaintext; + var dbEmbedJson = _encryption.EncryptDatabaseEnabled ? _encryption.EncryptNullable(embedJson) : embedJson; + var message = new Message { Id = Guid.NewGuid(), - Content = content, + Content = dbContent, Type = MessageType.Text, SentAt = DateTimeOffset.UtcNow, ChannelId = channel.Id, SenderUserId = userId, SenderUsername = username, - EmbedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null, + EmbedJson = dbEmbedJson, }; db.Messages.Add(message); await db.SaveChangesAsync(); + // Broadcast encrypted for SignalR clients; IRC broadcaster gets plaintext + var encryptedContent = _encryption.Encrypt(plaintext); var messageDto = new MessageDto( message.Id, - message.Content, + encryptedContent, message.SenderUsername, sender?.NicknameColor, channelName, @@ -398,7 +415,7 @@ public class ChatService : IChatService return string.Join('\n', result); } - private static async Task> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count) + private async Task> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count) { var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); if (channel is null) @@ -418,16 +435,21 @@ public class ChatService : IChatService return raw.Select(x => { + // Decrypt DB content (handles both encrypted and plaintext via prefix detection) + var plaintext = _encryption.Decrypt(x.m.Content); + var embedJsonPlain = _encryption.DecryptNullable(x.m.EmbedJson); + List? embeds = null; - if (x.m.EmbedJson is not null) + if (embedJsonPlain is not null) { - try { embeds = JsonSerializer.Deserialize>(x.m.EmbedJson); } + try { embeds = JsonSerializer.Deserialize>(embedJsonPlain); } catch { /* ignore malformed JSON */ } } + // Encrypt for transport — client decrypts return new MessageDto( x.m.Id, - x.m.Content, + _encryption.Encrypt(plaintext), x.m.SenderUsername, x.NicknameColor, channelName, diff --git a/src/EchoHub.Server/Services/MessageEncryptionService.cs b/src/EchoHub.Server/Services/MessageEncryptionService.cs new file mode 100644 index 0000000..a277cf0 --- /dev/null +++ b/src/EchoHub.Server/Services/MessageEncryptionService.cs @@ -0,0 +1,102 @@ +using System.Security.Cryptography; +using System.Text; +using EchoHub.Core.Contracts; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace EchoHub.Server.Services; + +public class MessageEncryptionService : IMessageEncryptionService +{ + private const string EncryptionPrefix = "$ENC$v1$"; + private const int NonceSizeBytes = 12; + private const int TagSizeBytes = 16; + + private readonly byte[] _key; + private readonly ILogger _logger; + + public bool EncryptDatabaseEnabled { get; } + + public MessageEncryptionService(IConfiguration configuration, ILogger logger) + { + _logger = logger; + + var keyBase64 = configuration["Encryption:Key"] + ?? throw new InvalidOperationException("Encryption:Key must be configured in appsettings.json."); + + _key = Convert.FromBase64String(keyBase64); + + if (_key.Length != 32) + throw new InvalidOperationException($"Encryption:Key must be exactly 32 bytes (256-bit). Got {_key.Length} bytes."); + + EncryptDatabaseEnabled = configuration.GetValue("Encryption:EncryptDatabase"); + } + + public string Encrypt(string plaintext) + { + var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + var nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes); + var ciphertext = new byte[plaintextBytes.Length]; + var tag = new byte[TagSizeBytes]; + + using var aes = new AesGcm(_key, TagSizeBytes); + aes.Encrypt(nonce, plaintextBytes, ciphertext, tag); + + // Combine ciphertext + tag for storage + var combined = new byte[ciphertext.Length + tag.Length]; + Buffer.BlockCopy(ciphertext, 0, combined, 0, ciphertext.Length); + Buffer.BlockCopy(tag, 0, combined, ciphertext.Length, tag.Length); + + return $"{EncryptionPrefix}{Convert.ToBase64String(nonce)}${Convert.ToBase64String(combined)}"; + } + + public string Decrypt(string content) + { + if (!content.StartsWith(EncryptionPrefix)) + return content; // Legacy plaintext + + try + { + var payload = content[EncryptionPrefix.Length..]; + var separatorIndex = payload.IndexOf('$'); + if (separatorIndex < 0) + { + _logger.LogWarning("Malformed encrypted content: missing separator"); + return "[encrypted message — decryption failed]"; + } + + var nonceBase64 = payload[..separatorIndex]; + var combinedBase64 = payload[(separatorIndex + 1)..]; + + var nonce = Convert.FromBase64String(nonceBase64); + var combined = Convert.FromBase64String(combinedBase64); + + if (combined.Length < TagSizeBytes) + { + _logger.LogWarning("Malformed encrypted content: data too short"); + return "[encrypted message — decryption failed]"; + } + + var ciphertextLength = combined.Length - TagSizeBytes; + var ciphertext = combined.AsSpan(0, ciphertextLength); + var tag = combined.AsSpan(ciphertextLength, TagSizeBytes); + var plaintext = new byte[ciphertextLength]; + + using var aes = new AesGcm(_key, TagSizeBytes); + aes.Decrypt(nonce, ciphertext, tag, plaintext); + + return Encoding.UTF8.GetString(plaintext); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to decrypt message content"); + return "[encrypted message — decryption failed]"; + } + } + + public string? EncryptNullable(string? value) + => value is null ? null : Encrypt(value); + + public string? DecryptNullable(string? value) + => value is null ? null : Decrypt(value); +} diff --git a/src/EchoHub.Server/Setup/DataMigrationService.cs b/src/EchoHub.Server/Setup/DataMigrationService.cs index 34028c8..a61e00b 100644 --- a/src/EchoHub.Server/Setup/DataMigrationService.cs +++ b/src/EchoHub.Server/Setup/DataMigrationService.cs @@ -136,4 +136,5 @@ public static partial class DataMigrationService logger.LogInformation("Migrated {Count} embed records from single-object to array format.", modified); } } + } diff --git a/src/EchoHub.Server/Setup/FirstRunSetup.cs b/src/EchoHub.Server/Setup/FirstRunSetup.cs index cda5b97..4170b6c 100644 --- a/src/EchoHub.Server/Setup/FirstRunSetup.cs +++ b/src/EchoHub.Server/Setup/FirstRunSetup.cs @@ -22,6 +22,7 @@ public static class FirstRunSetup return; EnsureJwtSecret(settingsPath); + EnsureEncryptionKey(settingsPath); } private static void EnsureJwtSecret(string settingsPath) @@ -45,4 +46,27 @@ public static class FirstRunSetup File.WriteAllText(settingsPath, root.ToJsonString(writeOptions)); Console.WriteLine("Generated new JWT secret in appsettings.json."); } + + private static void EnsureEncryptionKey(string settingsPath) + { + var json = File.ReadAllText(settingsPath); + var root = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip }); + if (root is null) + return; + + var currentKey = root["Encryption"]?["Key"]?.GetValue(); + + if (!string.IsNullOrEmpty(currentKey)) + return; + + // Generate a 256-bit (32-byte) AES key + var key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + + root["Encryption"] ??= new JsonObject(); + root["Encryption"]!["Key"] = key; + + var writeOptions = new JsonSerializerOptions { WriteIndented = true }; + File.WriteAllText(settingsPath, root.ToJsonString(writeOptions)); + Console.WriteLine("Generated new encryption key in appsettings.json."); + } } diff --git a/src/EchoHub.Server/appsettings.example.json b/src/EchoHub.Server/appsettings.example.json index 16d0607..ff89ee0 100644 --- a/src/EchoHub.Server/appsettings.example.json +++ b/src/EchoHub.Server/appsettings.example.json @@ -14,6 +14,10 @@ "PublicServer": false, "PublicHost": "" }, + "Encryption": { + "Key": "", + "EncryptDatabase": false + }, "Irc": { "Enabled": false, "Port": 6667, diff --git a/src/EchoHub.Tests/ClientEncryptionServiceTests.cs b/src/EchoHub.Tests/ClientEncryptionServiceTests.cs new file mode 100644 index 0000000..84a98ac --- /dev/null +++ b/src/EchoHub.Tests/ClientEncryptionServiceTests.cs @@ -0,0 +1,233 @@ +using System.Security.Cryptography; +using EchoHub.Client.Services; +using Xunit; + +namespace EchoHub.Tests; + +public class ClientEncryptionServiceTests +{ + private static string GenerateKey() => + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + + private static ClientEncryptionService CreateInitialized(string? key = null) + { + var service = new ClientEncryptionService(); + service.SetKey(key ?? GenerateKey()); + return service; + } + + // ── Initialization ─────────────────────────────────────────────── + + [Fact] + public void IsInitialized_DefaultFalse() + { + var service = new ClientEncryptionService(); + Assert.False(service.IsInitialized); + } + + [Fact] + public void IsInitialized_TrueAfterSetKey() + { + var service = CreateInitialized(); + Assert.True(service.IsInitialized); + } + + [Fact] + public void EncryptDatabaseEnabled_AlwaysFalse() + { + var service = CreateInitialized(); + Assert.False(service.EncryptDatabaseEnabled); + } + + [Fact] + public void SetKey_WrongLength_Throws() + { + var service = new ClientEncryptionService(); + var shortKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); + + Assert.Throws(() => service.SetKey(shortKey)); + } + + [Fact] + public void SetKey_InvalidBase64_Throws() + { + var service = new ClientEncryptionService(); + Assert.Throws(() => service.SetKey("not-valid-base64!!!")); + } + + // ── Encrypt before initialization ──────────────────────────────── + + [Fact] + public void Encrypt_NotInitialized_PassesThrough() + { + var service = new ClientEncryptionService(); + var result = service.Encrypt("hello"); + + Assert.Equal("hello", result); + } + + [Fact] + public void Decrypt_NotInitialized_PassesThrough() + { + var service = new ClientEncryptionService(); + var result = service.Decrypt("$ENC$v1$something$else"); + + Assert.Equal("$ENC$v1$something$else", result); + } + + // ── Encrypt after initialization ───────────────────────────────── + + [Fact] + public void Encrypt_ProducesEncryptedFormat() + { + var service = CreateInitialized(); + var encrypted = service.Encrypt("Hello, world!"); + + Assert.StartsWith("$ENC$v1$", encrypted); + } + + [Fact] + public void Encrypt_DifferentNonceEachTime() + { + var service = CreateInitialized(); + var a = service.Encrypt("same message"); + var b = service.Encrypt("same message"); + + Assert.NotEqual(a, b); + } + + [Fact] + public void Encrypt_EmptyString_Works() + { + var service = CreateInitialized(); + var encrypted = service.Encrypt(""); + + Assert.StartsWith("$ENC$v1$", encrypted); + } + + // ── Decrypt ────────────────────────────────────────────────────── + + [Fact] + public void Decrypt_RoundTrip() + { + var service = CreateInitialized(); + var original = "Hello, world!"; + + var encrypted = service.Encrypt(original); + var decrypted = service.Decrypt(encrypted); + + Assert.Equal(original, decrypted); + } + + [Fact] + public void Decrypt_EmptyString_RoundTrip() + { + var service = CreateInitialized(); + var encrypted = service.Encrypt(""); + var decrypted = service.Decrypt(encrypted); + + Assert.Equal("", decrypted); + } + + [Fact] + public void Decrypt_Unicode_RoundTrip() + { + var service = CreateInitialized(); + var original = "Hello 🌍 世界 مرحبا"; + + var encrypted = service.Encrypt(original); + var decrypted = service.Decrypt(encrypted); + + Assert.Equal(original, decrypted); + } + + [Fact] + public void Decrypt_PlaintextPassthrough() + { + var service = CreateInitialized(); + var plaintext = "This is just plain text"; + + var result = service.Decrypt(plaintext); + + Assert.Equal(plaintext, result); + } + + [Fact] + public void Decrypt_WrongKey_ReturnsFailureMessage() + { + var encryptor = CreateInitialized(); + var decryptor = CreateInitialized(); // different key + + var encrypted = encryptor.Encrypt("secret message"); + var result = decryptor.Decrypt(encrypted); + + Assert.Contains("decryption failed", result); + } + + [Fact] + public void Decrypt_MalformedContent_ReturnsOriginal() + { + var service = CreateInitialized(); + + // Malformed: prefix present but no valid separator after nonce + var result = service.Decrypt("$ENC$v1$noseperatorhere"); + + Assert.Equal("$ENC$v1$noseperatorhere", result); + } + + [Fact] + public void Decrypt_TamperedCiphertext_ReturnsFailure() + { + var service = CreateInitialized(); + var encrypted = service.Encrypt("original message"); + + var tampered = encrypted[..^5] + "XXXXX"; + var result = service.Decrypt(tampered); + + Assert.Contains("decryption failed", result); + } + + // ── Nullable helpers ───────────────────────────────────────────── + + [Fact] + public void EncryptNullable_Null_ReturnsNull() + { + var service = CreateInitialized(); + Assert.Null(service.EncryptNullable(null)); + } + + [Fact] + public void DecryptNullable_Null_ReturnsNull() + { + var service = CreateInitialized(); + Assert.Null(service.DecryptNullable(null)); + } + + // ── Key replacement ────────────────────────────────────────────── + + [Fact] + public void SetKey_CanBeCalledMultipleTimes() + { + var service = new ClientEncryptionService(); + + var key1 = GenerateKey(); + var key2 = GenerateKey(); + + service.SetKey(key1); + var encrypted1 = service.Encrypt("test"); + + service.SetKey(key2); + var encrypted2 = service.Encrypt("test"); + + // Both produce encrypted content + Assert.StartsWith("$ENC$v1$", encrypted1); + Assert.StartsWith("$ENC$v1$", encrypted2); + + // Old key's content can't be decrypted with new key + var result = service.Decrypt(encrypted1); + Assert.Contains("decryption failed", result); + + // New key's content decrypts fine + Assert.Equal("test", service.Decrypt(encrypted2)); + } +} diff --git a/src/EchoHub.Tests/EchoHub.Tests.csproj b/src/EchoHub.Tests/EchoHub.Tests.csproj index 473821e..1a9b2a8 100644 --- a/src/EchoHub.Tests/EchoHub.Tests.csproj +++ b/src/EchoHub.Tests/EchoHub.Tests.csproj @@ -18,6 +18,7 @@ + diff --git a/src/EchoHub.Tests/EncryptionCompatibilityTests.cs b/src/EchoHub.Tests/EncryptionCompatibilityTests.cs new file mode 100644 index 0000000..73911ab --- /dev/null +++ b/src/EchoHub.Tests/EncryptionCompatibilityTests.cs @@ -0,0 +1,208 @@ +using System.Security.Cryptography; +using EchoHub.Client.Services; +using EchoHub.Server.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace EchoHub.Tests; + +/// +/// Tests that server and client encryption services are fully interoperable — +/// content encrypted by one can be decrypted by the other using the same key. +/// +public class EncryptionCompatibilityTests +{ + private static readonly string SharedKey = + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + + private static MessageEncryptionService CreateServer(string? key = null) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Encryption:Key"] = key ?? SharedKey, + ["Encryption:EncryptDatabase"] = "false", + }) + .Build(); + + var logger = NullLoggerFactory.Instance.CreateLogger(); + return new MessageEncryptionService(config, logger); + } + + private static ClientEncryptionService CreateClient(string? key = null) + { + var service = new ClientEncryptionService(); + service.SetKey(key ?? SharedKey); + return service; + } + + // ── Cross-service round trips ──────────────────────────────────── + + [Fact] + public void ClientEncrypt_ServerDecrypt() + { + var client = CreateClient(); + var server = CreateServer(); + var original = "Hello from client!"; + + var encrypted = client.Encrypt(original); + var decrypted = server.Decrypt(encrypted); + + Assert.Equal(original, decrypted); + } + + [Fact] + public void ServerEncrypt_ClientDecrypt() + { + var server = CreateServer(); + var client = CreateClient(); + var original = "Hello from server!"; + + var encrypted = server.Encrypt(original); + var decrypted = client.Decrypt(encrypted); + + Assert.Equal(original, decrypted); + } + + [Theory] + [InlineData("")] + [InlineData("a")] + [InlineData("Hello, world!")] + [InlineData("Hello 🌍 世界 مرحبا")] + [InlineData("Line1\nLine2\nLine3")] + public void CrossDecrypt_VariousMessages(string message) + { + var server = CreateServer(); + var client = CreateClient(); + + // Client → Server + var clientEncrypted = client.Encrypt(message); + Assert.Equal(message, server.Decrypt(clientEncrypted)); + + // Server → Client + var serverEncrypted = server.Encrypt(message); + Assert.Equal(message, client.Decrypt(serverEncrypted)); + } + + [Fact] + public void CrossDecrypt_LargeMessage() + { + var server = CreateServer(); + var client = CreateClient(); + var original = new string('X', 10000); + + var encrypted = client.Encrypt(original); + Assert.Equal(original, server.Decrypt(encrypted)); + } + + // ── Key mismatch ───────────────────────────────────────────────── + + [Fact] + public void DifferentKeys_ClientEncrypt_ServerCantDecrypt() + { + var clientKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + var serverKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + + var client = CreateClient(clientKey); + var server = CreateServer(serverKey); + + var encrypted = client.Encrypt("secret"); + var result = server.Decrypt(encrypted); + + Assert.Contains("decryption failed", result); + } + + [Fact] + public void DifferentKeys_ServerEncrypt_ClientCantDecrypt() + { + var clientKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + var serverKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + + var server = CreateServer(serverKey); + var client = CreateClient(clientKey); + + var encrypted = server.Encrypt("secret"); + var result = client.Decrypt(encrypted); + + Assert.Contains("decryption failed", result); + } + + // ── Plaintext passthrough ──────────────────────────────────────── + + [Fact] + public void Server_DecryptsPlaintext_AsPassthrough() + { + var server = CreateServer(); + Assert.Equal("plain text", server.Decrypt("plain text")); + } + + [Fact] + public void Client_DecryptsPlaintext_AsPassthrough() + { + var client = CreateClient(); + Assert.Equal("plain text", client.Decrypt("plain text")); + } + + // ── Full E2E flow simulation ───────────────────────────────────── + + [Fact] + public void FullFlow_ClientSend_ServerProcess_BroadcastBack() + { + var client = CreateClient(); + var server = CreateServer(); + + // 1. Client encrypts and sends + var originalMessage = "Hello everyone!"; + var clientEncrypted = client.Encrypt(originalMessage); + Assert.StartsWith("$ENC$v1$", clientEncrypted); + + // 2. Server decrypts for processing + var serverPlaintext = server.Decrypt(clientEncrypted); + Assert.Equal(originalMessage, serverPlaintext); + + // 3. Server re-encrypts for broadcast (different nonce) + var serverEncrypted = server.Encrypt(serverPlaintext); + Assert.StartsWith("$ENC$v1$", serverEncrypted); + Assert.NotEqual(clientEncrypted, serverEncrypted); // different nonce + + // 4. Receiving client decrypts the broadcast + var receivedPlaintext = client.Decrypt(serverEncrypted); + Assert.Equal(originalMessage, receivedPlaintext); + } + + [Fact] + public void FullFlow_ServerGeneratedMessage_EncryptForBroadcast() + { + var server = CreateServer(); + var client = CreateClient(); + + // Server generates a system message (e.g. file upload notification) + var systemMessage = "user uploaded file.png"; + + // Server encrypts for broadcast + var encrypted = server.Encrypt(systemMessage); + + // Client decrypts + var decrypted = client.Decrypt(encrypted); + Assert.Equal(systemMessage, decrypted); + } + + [Fact] + public void FullFlow_HistoryRetrieve_ServerEncrypts_ClientDecrypts() + { + var server = CreateServer(); + var client = CreateClient(); + + // Simulate loading N messages from DB (plaintext) and encrypting for transport + var messages = new[] { "msg1", "Hello 🌍", "msg with\nnewline" }; + + foreach (var original in messages) + { + var encrypted = server.Encrypt(original); + var decrypted = client.Decrypt(encrypted); + Assert.Equal(original, decrypted); + } + } +} diff --git a/src/EchoHub.Tests/MessageEncryptionServiceTests.cs b/src/EchoHub.Tests/MessageEncryptionServiceTests.cs new file mode 100644 index 0000000..5e76864 --- /dev/null +++ b/src/EchoHub.Tests/MessageEncryptionServiceTests.cs @@ -0,0 +1,259 @@ +using System.Security.Cryptography; +using EchoHub.Server.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace EchoHub.Tests; + +public class MessageEncryptionServiceTests +{ + private static MessageEncryptionService CreateService( + string? key = null, bool encryptDatabase = false) + { + key ??= Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Encryption:Key"] = key, + ["Encryption:EncryptDatabase"] = encryptDatabase.ToString(), + }) + .Build(); + + var logger = NullLoggerFactory.Instance.CreateLogger(); + return new MessageEncryptionService(config, logger); + } + + // ── Constructor validation ─────────────────────────────────────── + + [Fact] + public void Constructor_MissingKey_Throws() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); + + var logger = NullLoggerFactory.Instance.CreateLogger(); + + Assert.Throws(() => + new MessageEncryptionService(config, logger)); + } + + [Fact] + public void Constructor_WrongKeyLength_Throws() + { + var shortKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); + + Assert.Throws(() => CreateService(shortKey)); + } + + [Fact] + public void Constructor_ValidKey_Succeeds() + { + var service = CreateService(); + Assert.NotNull(service); + } + + // ── EncryptDatabaseEnabled ─────────────────────────────────────── + + [Fact] + public void EncryptDatabaseEnabled_DefaultFalse() + { + var service = CreateService(); + Assert.False(service.EncryptDatabaseEnabled); + } + + [Fact] + public void EncryptDatabaseEnabled_WhenConfiguredTrue() + { + var service = CreateService(encryptDatabase: true); + Assert.True(service.EncryptDatabaseEnabled); + } + + // ── Encrypt ────────────────────────────────────────────────────── + + [Fact] + public void Encrypt_ProducesEncryptedFormat() + { + var service = CreateService(); + var encrypted = service.Encrypt("Hello, world!"); + + Assert.StartsWith("$ENC$v1$", encrypted); + } + + [Fact] + public void Encrypt_DifferentNonceEachTime() + { + var service = CreateService(); + var a = service.Encrypt("same message"); + var b = service.Encrypt("same message"); + + Assert.NotEqual(a, b); + } + + [Fact] + public void Encrypt_EmptyString_Works() + { + var service = CreateService(); + var encrypted = service.Encrypt(""); + + Assert.StartsWith("$ENC$v1$", encrypted); + } + + [Fact] + public void Encrypt_Unicode_Works() + { + var service = CreateService(); + var encrypted = service.Encrypt("Hello 🌍 世界 مرحبا"); + + Assert.StartsWith("$ENC$v1$", encrypted); + } + + // ── Decrypt ────────────────────────────────────────────────────── + + [Fact] + public void Decrypt_RoundTrip() + { + var service = CreateService(); + var original = "Hello, world!"; + + var encrypted = service.Encrypt(original); + var decrypted = service.Decrypt(encrypted); + + Assert.Equal(original, decrypted); + } + + [Fact] + public void Decrypt_EmptyString_RoundTrip() + { + var service = CreateService(); + var encrypted = service.Encrypt(""); + var decrypted = service.Decrypt(encrypted); + + Assert.Equal("", decrypted); + } + + [Fact] + public void Decrypt_Unicode_RoundTrip() + { + var service = CreateService(); + var original = "Hello 🌍 世界 مرحبا"; + + var encrypted = service.Encrypt(original); + var decrypted = service.Decrypt(encrypted); + + Assert.Equal(original, decrypted); + } + + [Fact] + public void Decrypt_LongMessage_RoundTrip() + { + var service = CreateService(); + var original = new string('A', 5000); + + var encrypted = service.Encrypt(original); + var decrypted = service.Decrypt(encrypted); + + Assert.Equal(original, decrypted); + } + + [Fact] + public void Decrypt_PlaintextPassthrough() + { + var service = CreateService(); + var plaintext = "This is just plain text"; + + var result = service.Decrypt(plaintext); + + Assert.Equal(plaintext, result); + } + + [Fact] + public void Decrypt_WrongKey_ReturnsFailureMessage() + { + var key1 = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + var key2 = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + + var encryptor = CreateService(key1); + var decryptor = CreateService(key2); + + var encrypted = encryptor.Encrypt("secret message"); + var result = decryptor.Decrypt(encrypted); + + Assert.Contains("decryption failed", result); + } + + [Fact] + public void Decrypt_MalformedContent_MissingSeparator_ReturnsFailure() + { + var service = CreateService(); + var malformed = "$ENC$v1$noseperatorhere"; + + var result = service.Decrypt(malformed); + + Assert.Contains("decryption failed", result); + } + + [Fact] + public void Decrypt_MalformedContent_TooShort_ReturnsFailure() + { + var service = CreateService(); + var malformed = "$ENC$v1$AAAA$BBBB"; + + var result = service.Decrypt(malformed); + + Assert.Contains("decryption failed", result); + } + + [Fact] + public void Decrypt_TamperedCiphertext_ReturnsFailure() + { + var service = CreateService(); + var encrypted = service.Encrypt("original message"); + + // Tamper with the ciphertext by flipping a character + var tampered = encrypted[..^5] + "XXXXX"; + var result = service.Decrypt(tampered); + + Assert.Contains("decryption failed", result); + } + + // ── Nullable helpers ───────────────────────────────────────────── + + [Fact] + public void EncryptNullable_Null_ReturnsNull() + { + var service = CreateService(); + Assert.Null(service.EncryptNullable(null)); + } + + [Fact] + public void EncryptNullable_Value_Encrypts() + { + var service = CreateService(); + var result = service.EncryptNullable("test"); + + Assert.NotNull(result); + Assert.StartsWith("$ENC$v1$", result); + } + + [Fact] + public void DecryptNullable_Null_ReturnsNull() + { + var service = CreateService(); + Assert.Null(service.DecryptNullable(null)); + } + + [Fact] + public void DecryptNullable_EncryptedValue_Decrypts() + { + var service = CreateService(); + var encrypted = service.Encrypt("test"); + + var result = service.DecryptNullable(encrypted); + + Assert.Equal("test", result); + } +}