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:
HueByte
2026-02-20 17:16:32 +01:00
parent dfa1b21d32
commit 2efe54e417
26 changed files with 1517 additions and 31 deletions
@@ -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));
}
}
+2 -2
View File
@@ -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 =>
@@ -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")
+3
View File
@@ -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>();
+35 -13
View File
@@ -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);
}
}
}
+24
View File
@@ -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,