mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: Implement end-to-end encryption for channels
- Added EncryptionSalt and WrappedRoomKey properties to Channel model. - Introduced RoomCrypto class for client-side encryption and decryption. - Updated ChannelService to handle encrypted channels, including creation and rekeying. - Modified ChannelsController to expose crypto metadata and rekey functionality. - Enhanced IrcCommandHandler to block joining encrypted channels over IRC. - Updated database schema with migration for new encryption fields. - Refactored file validation and image processing services to accommodate encrypted channels. - Added unit tests for RoomCrypto functionality and updated existing tests for channel services.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
using EchoHub.Server.Services;
|
||||
using EchoHub.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace EchoHub.Tests;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.Services;
|
||||
using EchoHub.Server.Services;
|
||||
using Xunit;
|
||||
|
||||
|
||||
@@ -287,6 +287,17 @@ public class IrcCommandHandlerTests
|
||||
Assert.Contains(lines, l => l.Contains("475") && l.Contains("#secret") && l.Contains("+k"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Join_EncryptedChannel_IsBlockedOverIrc()
|
||||
{
|
||||
_channelService.CryptoToReturn = new ChannelCryptoDto(true, "c2FsdA==");
|
||||
|
||||
var lines = await RunAuthenticated(["JOIN #vault"]);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("475") && l.Contains("#vault") && l.Contains("end-to-end encrypted"));
|
||||
Assert.Empty(_chatService.JoinedChannels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Join_SendsTopic()
|
||||
{
|
||||
|
||||
@@ -234,9 +234,24 @@ internal sealed class FakeChannelService : IChannelService
|
||||
public Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit) =>
|
||||
Task.FromResult(new PaginatedResponse<ChannelDto>([], 0, offset, limit));
|
||||
|
||||
public Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null) =>
|
||||
public ChannelCryptoDto? CryptoToReturn { get; set; }
|
||||
public ChannelOperationResult? RekeyResult { get; set; }
|
||||
public (string? EncryptionSalt, string? WrappedRoomKey) KeyEnvelopeToReturn { get; set; }
|
||||
|
||||
public Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic,
|
||||
string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null) =>
|
||||
Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
|
||||
|
||||
public Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName) =>
|
||||
Task.FromResult(CryptoToReturn);
|
||||
|
||||
public Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName) =>
|
||||
Task.FromResult(KeyEnvelopeToReturn);
|
||||
|
||||
public Task<ChannelOperationResult> RekeyChannelAsync(Guid callerUserId, string channelName,
|
||||
string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey) =>
|
||||
Task.FromResult(RekeyResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
|
||||
|
||||
public Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) =>
|
||||
Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using EchoHub.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace EchoHub.Tests;
|
||||
|
||||
public class RoomCryptoTests
|
||||
{
|
||||
[Fact]
|
||||
public void EncryptText_RoundTrips()
|
||||
{
|
||||
var key = RoomCrypto.GenerateRoomKey();
|
||||
var ciphertext = RoomCrypto.EncryptText("hello secret room", key);
|
||||
|
||||
Assert.StartsWith("$RC1$", ciphertext);
|
||||
Assert.True(RoomCrypto.TryDecryptText(ciphertext, key, out var plaintext));
|
||||
Assert.Equal("hello secret room", plaintext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryDecryptText_WrongKey_ReturnsFalse()
|
||||
{
|
||||
var ciphertext = RoomCrypto.EncryptText("hello", RoomCrypto.GenerateRoomKey());
|
||||
|
||||
Assert.False(RoomCrypto.TryDecryptText(ciphertext, RoomCrypto.GenerateRoomKey(), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryDecryptText_PlainText_ReturnsFalse()
|
||||
{
|
||||
Assert.False(RoomCrypto.TryDecryptText("just a normal message", RoomCrypto.GenerateRoomKey(), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncryptBytes_RoundTrips()
|
||||
{
|
||||
var key = RoomCrypto.GenerateRoomKey();
|
||||
var payload = new byte[4096];
|
||||
Random.Shared.NextBytes(payload);
|
||||
|
||||
var blob = RoomCrypto.EncryptBytes(payload, key);
|
||||
var decrypted = RoomCrypto.DecryptBytes(blob, key);
|
||||
|
||||
Assert.Equal(payload, decrypted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveKeys_IsDeterministic_AndSaltSensitive()
|
||||
{
|
||||
var salt = RoomCrypto.GenerateSalt();
|
||||
var a = RoomCrypto.DeriveKeys("correct horse battery staple", salt);
|
||||
var b = RoomCrypto.DeriveKeys("correct horse battery staple", salt);
|
||||
var other = RoomCrypto.DeriveKeys("correct horse battery staple", RoomCrypto.GenerateSalt());
|
||||
|
||||
Assert.Equal(a.AuthKeyHex, b.AuthKeyHex);
|
||||
Assert.Equal(a.KeyEncryptionKey, b.KeyEncryptionKey);
|
||||
Assert.NotEqual(a.AuthKeyHex, other.AuthKeyHex);
|
||||
Assert.NotEqual(a.AuthKeyHex, Convert.ToHexString(a.KeyEncryptionKey).ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrapRoomKey_UnwrapsWithSameKek_FailsWithWrongKek()
|
||||
{
|
||||
var salt = RoomCrypto.GenerateSalt();
|
||||
var keys = RoomCrypto.DeriveKeys("passphrase-1", salt);
|
||||
var wrongKeys = RoomCrypto.DeriveKeys("passphrase-2", salt);
|
||||
var roomKey = RoomCrypto.GenerateRoomKey();
|
||||
|
||||
var wrapped = RoomCrypto.WrapRoomKey(roomKey, keys.KeyEncryptionKey);
|
||||
|
||||
Assert.True(RoomCrypto.TryUnwrapRoomKey(wrapped, keys.KeyEncryptionKey, out var unwrapped));
|
||||
Assert.Equal(roomKey, unwrapped);
|
||||
Assert.False(RoomCrypto.TryUnwrapRoomKey(wrapped, wrongKeys.KeyEncryptionKey, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rewrap_PreservesRoomKey_AcrossPassphraseChange()
|
||||
{
|
||||
// Simulates a passphrase change: unwrap with old KEK, wrap with new KEK.
|
||||
var roomKey = RoomCrypto.GenerateRoomKey();
|
||||
|
||||
var oldSalt = RoomCrypto.GenerateSalt();
|
||||
var oldKeys = RoomCrypto.DeriveKeys("old-passphrase", oldSalt);
|
||||
var wrappedOld = RoomCrypto.WrapRoomKey(roomKey, oldKeys.KeyEncryptionKey);
|
||||
|
||||
Assert.True(RoomCrypto.TryUnwrapRoomKey(wrappedOld, oldKeys.KeyEncryptionKey, out var recovered));
|
||||
|
||||
var newSalt = RoomCrypto.GenerateSalt();
|
||||
var newKeys = RoomCrypto.DeriveKeys("new-passphrase", newSalt);
|
||||
var wrappedNew = RoomCrypto.WrapRoomKey(recovered, newKeys.KeyEncryptionKey);
|
||||
|
||||
Assert.True(RoomCrypto.TryUnwrapRoomKey(wrappedNew, newKeys.KeyEncryptionKey, out var final));
|
||||
Assert.Equal(roomKey, final);
|
||||
|
||||
// Old messages encrypted before the change still decrypt with the unwrapped key
|
||||
var oldMessage = RoomCrypto.EncryptText("written before rekey", roomKey);
|
||||
Assert.True(RoomCrypto.TryDecryptText(oldMessage, final, out var plaintext));
|
||||
Assert.Equal("written before rekey", plaintext);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user