mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>0.2.3</Version>
|
||||
<Version>0.2.4</Version>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -128,6 +128,16 @@ public sealed class ApiClient : IDisposable
|
||||
return info;
|
||||
}
|
||||
|
||||
public async Task<string> GetEncryptionKeyAsync()
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var response = await AuthenticatedGetAsync("/api/server/encryption-key");
|
||||
await EnsureSuccessAsync(response);
|
||||
var result = await response.Content.ReadFromJsonAsync<EncryptionKeyResponse>()
|
||||
?? throw new InvalidOperationException("Server returned empty encryption key response.");
|
||||
return result.Key;
|
||||
}
|
||||
|
||||
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using EchoHub.Core.Contracts;
|
||||
|
||||
namespace EchoHub.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Initialize with the server's encryption key (fetched after login).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ namespace EchoHub.Client.Services;
|
||||
public sealed class EchoHubConnection : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _connection;
|
||||
private readonly ClientEncryptionService _encryption;
|
||||
|
||||
public event Action<MessageDto>? OnMessageReceived;
|
||||
public event Action<string, string>? 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<MessageDto>(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<string, string>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) =>
|
||||
@@ -132,7 +136,8 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
|
||||
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
||||
{
|
||||
return await _connection.InvokeAsync<List<MessageDto>>("JoinChannel", channelName);
|
||||
var messages = await _connection.InvokeAsync<List<MessageDto>>("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<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount)
|
||||
{
|
||||
return await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
|
||||
var messages = await _connection.InvokeAsync<List<MessageDto>>("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<List<UserPresenceDto>>("GetOnlineUsers", channelName);
|
||||
}
|
||||
|
||||
private List<MessageDto> DecryptMessages(List<MessageDto> messages)
|
||||
{
|
||||
return messages.Select(m => m with { Content = _encryption.Decrypt(m.Content) }).ToList();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _connection.DisposeAsync();
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace EchoHub.Core.Contracts;
|
||||
|
||||
public interface IMessageEncryptionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether database content should be encrypted at rest (server setting).
|
||||
/// </summary>
|
||||
bool EncryptDatabaseEnabled { get; }
|
||||
|
||||
string Encrypt(string plaintext);
|
||||
string Decrypt(string content);
|
||||
string? EncryptNullable(string? value);
|
||||
string? DecryptNullable(string? value);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
namespace EchoHub.Core.DTOs;
|
||||
|
||||
public record ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels);
|
||||
|
||||
public record EncryptionKeyResponse(string Key);
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ChannelMembership>(entity =>
|
||||
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("CreatedByUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Channels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AttachmentFileName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AttachmentUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EmbedJson")
|
||||
.HasMaxLength(32000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SenderUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SenderUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SentAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChannelId");
|
||||
|
||||
b.HasIndex("SentAt");
|
||||
|
||||
b.ToTable("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ExpiresAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("RevokedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AvatarAscii")
|
||||
.HasMaxLength(10000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsMuted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("LastSeenAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("MutedUntil")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("NicknameColor")
|
||||
.HasMaxLength(7)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StatusMessage")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EchoHub.Server.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEncryptionSupport : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,11 +88,11 @@ namespace EchoHub.Server.Data.Migrations
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasMaxLength(16000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EmbedJson")
|
||||
.HasMaxLength(8000)
|
||||
.HasMaxLength(32000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SenderUserId")
|
||||
|
||||
@@ -109,6 +109,9 @@ while (true)
|
||||
builder.Services.AddSingleton<LinkEmbedService>();
|
||||
builder.Services.AddHostedService<ServerDirectoryService>();
|
||||
|
||||
// ── Encryption ─────────────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
|
||||
|
||||
// ── Chat Service + Broadcasters ─────────────────────────────────────
|
||||
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
|
||||
builder.Services.AddSingleton<IChatService, ChatService>();
|
||||
|
||||
@@ -16,6 +16,7 @@ public class ChatService : IChatService
|
||||
private readonly PresenceTracker _presenceTracker;
|
||||
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||
private readonly LinkEmbedService _embedService;
|
||||
private readonly IMessageEncryptionService _encryption;
|
||||
private readonly ILogger<ChatService> _logger;
|
||||
|
||||
public ChatService(
|
||||
@@ -23,12 +24,14 @@ public class ChatService : IChatService
|
||||
PresenceTracker presenceTracker,
|
||||
IEnumerable<IChatBroadcaster> broadcasters,
|
||||
LinkEmbedService embedService,
|
||||
IMessageEncryptionService encryption,
|
||||
ILogger<ChatService> 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<EchoHubDbContext>();
|
||||
@@ -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<EmbedDto>? 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<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
|
||||
private async Task<List<MessageDto>> 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<EmbedDto>? embeds = null;
|
||||
if (x.m.EmbedJson is not null)
|
||||
if (embedJsonPlain is not null)
|
||||
{
|
||||
try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(x.m.EmbedJson); }
|
||||
try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(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,
|
||||
|
||||
@@ -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<MessageEncryptionService> _logger;
|
||||
|
||||
public bool EncryptDatabaseEnabled { get; }
|
||||
|
||||
public MessageEncryptionService(IConfiguration configuration, ILogger<MessageEncryptionService> 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<bool>("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);
|
||||
}
|
||||
@@ -136,4 +136,5 @@ public static partial class DataMigrationService
|
||||
logger.LogInformation("Migrated {Count} embed records from single-object to array format.", modified);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
"PublicServer": false,
|
||||
"PublicHost": ""
|
||||
},
|
||||
"Encryption": {
|
||||
"Key": "",
|
||||
"EncryptDatabase": false
|
||||
},
|
||||
"Irc": {
|
||||
"Enabled": false,
|
||||
"Port": 6667,
|
||||
|
||||
@@ -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<InvalidOperationException>(() => service.SetKey(shortKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetKey_InvalidBase64_Throws()
|
||||
{
|
||||
var service = new ClientEncryptionService();
|
||||
Assert.Throws<FormatException>(() => 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));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\EchoHub.Client\EchoHub.Client.csproj" />
|
||||
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
||||
<ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that server and client encryption services are fully interoperable —
|
||||
/// content encrypted by one can be decrypted by the other using the same key.
|
||||
/// </summary>
|
||||
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<string, string?>
|
||||
{
|
||||
["Encryption:Key"] = key ?? SharedKey,
|
||||
["Encryption:EncryptDatabase"] = "false",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var logger = NullLoggerFactory.Instance.CreateLogger<MessageEncryptionService>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string?>
|
||||
{
|
||||
["Encryption:Key"] = key,
|
||||
["Encryption:EncryptDatabase"] = encryptDatabase.ToString(),
|
||||
})
|
||||
.Build();
|
||||
|
||||
var logger = NullLoggerFactory.Instance.CreateLogger<MessageEncryptionService>();
|
||||
return new MessageEncryptionService(config, logger);
|
||||
}
|
||||
|
||||
// ── Constructor validation ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Constructor_MissingKey_Throws()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>())
|
||||
.Build();
|
||||
|
||||
var logger = NullLoggerFactory.Instance.CreateLogger<MessageEncryptionService>();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
new MessageEncryptionService(config, logger));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WrongKeyLength_Throws()
|
||||
{
|
||||
var shortKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user