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:
HueByte
2026-07-16 03:50:23 +02:00
parent ea8e583ee5
commit e05b420ce9
36 changed files with 1400 additions and 67 deletions
+96 -7
View File
@@ -41,14 +41,16 @@ public class ChannelService : IChannelService
.Skip(offset)
.Take(limit)
.Select(c => new ChannelDto(
c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt, c.PasswordHash != null))
c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt,
c.PasswordHash != null, c.WrappedRoomKey != null))
.ToListAsync();
return new PaginatedResponse<ChannelDto>(channels, total, offset, limit);
}
public async Task<ChannelOperationResult> CreateChannelAsync(
Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null)
Guid creatorUserId, string name, string? topic, bool isPublic,
string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null)
{
if (string.IsNullOrWhiteSpace(name))
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Channel name is required.");
@@ -63,6 +65,12 @@ public class ChannelService : IChannelService
if (passwordError is not null)
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError);
// The E2E envelope (client-generated) only makes sense on password-gated channels
var hasEnvelope = !string.IsNullOrWhiteSpace(encryptionSalt) && !string.IsNullOrWhiteSpace(wrappedRoomKey);
if (hasEnvelope && password is null)
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
"Encrypted channels require a password.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
@@ -77,6 +85,8 @@ public class ChannelService : IChannelService
IsPublic = isPublic,
CreatedByUserId = creatorUserId,
PasswordHash = password is not null ? BCrypt.Net.BCrypt.HashPassword(password) : null,
EncryptionSalt = hasEnvelope ? encryptionSalt : null,
WrappedRoomKey = hasEnvelope ? wrappedRoomKey : null,
};
db.Channels.Add(channel);
@@ -91,7 +101,7 @@ public class ChannelService : IChannelService
await db.SaveChangesAsync();
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt,
channel.PasswordHash != null);
channel.PasswordHash != null, channel.WrappedRoomKey != null);
return ChannelOperationResult.Success(dto);
}
@@ -119,12 +129,14 @@ public class ChannelService : IChannelService
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt,
dbChannel.PasswordHash != null);
dbChannel.PasswordHash != null, dbChannel.WrappedRoomKey != null);
return ChannelOperationResult.Success(dto);
}
/// <summary>
/// Sets, changes, or clears (null) a channel's join password. Creator or admin only.
/// Not available on end-to-end encrypted channels — those change passphrase via
/// <see cref="RekeyChannelAsync"/> so the room key envelope stays consistent.
/// </summary>
public async Task<ChannelOperationResult> SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password)
{
@@ -141,6 +153,10 @@ public class ChannelService : IChannelService
if (dbChannel is null)
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
if (dbChannel.WrappedRoomKey is not null)
return ChannelOperationResult.Fail(ChannelError.Protected,
"This channel is end-to-end encrypted — change its passphrase from the EchoHub client (/passwd).");
var caller = await db.Users.FindAsync(callerUserId);
if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin))
return ChannelOperationResult.Fail(ChannelError.Forbidden,
@@ -151,7 +167,55 @@ public class ChannelService : IChannelService
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt,
dbChannel.PasswordHash != null);
dbChannel.PasswordHash != null, dbChannel.WrappedRoomKey != null);
return ChannelOperationResult.Success(dto);
}
/// <summary>
/// Changes an encrypted channel's passphrase by swapping the join-gate hash and the
/// wrapped room key. The room content key itself never changes, so history stays
/// readable — the client re-wraps it under the new passphrase-derived key.
/// Creator only: admins cannot rekey a room whose passphrase they don't know.
/// </summary>
public async Task<ChannelOperationResult> RekeyChannelAsync(Guid callerUserId, string channelName,
string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey)
{
channelName = channelName.ToLowerInvariant().Trim();
string? validatedNew = newPassword;
var passwordError = ValidateChannelPassword(ref validatedNew);
if (passwordError is not null)
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError);
if (validatedNew is null || string.IsNullOrWhiteSpace(newEncryptionSalt) || string.IsNullOrWhiteSpace(newWrappedRoomKey))
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
"New password, salt, and wrapped room key are required.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
if (dbChannel.WrappedRoomKey is null || dbChannel.PasswordHash is null)
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
"This channel is not end-to-end encrypted.");
if (dbChannel.CreatedByUserId != callerUserId)
return ChannelOperationResult.Fail(ChannelError.Forbidden,
"Only the channel creator can change the passphrase.");
if (!BCrypt.Net.BCrypt.Verify(oldPassword, dbChannel.PasswordHash))
return ChannelOperationResult.Fail(ChannelError.Forbidden, "The current passphrase is incorrect.");
dbChannel.PasswordHash = BCrypt.Net.BCrypt.HashPassword(validatedNew);
dbChannel.EncryptionSalt = newEncryptionSalt;
dbChannel.WrappedRoomKey = newWrappedRoomKey;
await db.SaveChangesAsync();
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt,
true, true);
return ChannelOperationResult.Success(dto);
}
@@ -179,7 +243,7 @@ public class ChannelService : IChannelService
await db.SaveChangesAsync();
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, 0, dbChannel.CreatedAt,
dbChannel.PasswordHash != null);
dbChannel.PasswordHash != null, dbChannel.WrappedRoomKey != null);
return ChannelOperationResult.Success(dto);
}
@@ -220,7 +284,32 @@ public class ChannelService : IChannelService
if (c is null) return null;
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id);
return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt, c.PasswordHash != null);
return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt,
c.PasswordHash != null, c.WrappedRoomKey != null);
}
public async Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var c = await db.Channels.FirstOrDefaultAsync(ch => ch.Name == channelName);
if (c is null) return null;
return new ChannelCryptoDto(c.WrappedRoomKey != null, c.EncryptionSalt);
}
public async Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var c = await db.Channels.FirstOrDefaultAsync(ch => ch.Name == channelName);
return (c?.EncryptionSalt, c?.WrappedRoomKey);
}
public async Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(