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
@@ -6,15 +6,20 @@ public interface IChannelService
{
// Channel CRUD
Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit);
Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null);
Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic,
string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null);
Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic);
Task<ChannelOperationResult> SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password);
Task<ChannelOperationResult> RekeyChannelAsync(Guid callerUserId, string channelName,
string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey);
Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName);
// Channel queries
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
Task<List<ChannelListItem>> GetChannelListAsync();
Task<ChannelDto?> GetChannelByNameAsync(string channelName);
Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName);
Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName);
// Membership
Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null);
+32 -3
View File
@@ -22,7 +22,8 @@ public record ChannelDto(
bool IsPublic,
int MessageCount,
DateTimeOffset CreatedAt,
bool IsProtected = false);
bool IsProtected = false,
bool IsEncrypted = false);
public record UserDto(
Guid Id,
@@ -34,13 +35,41 @@ public record UserDto(
public record SendMessageRequest(string ChannelName, string Content);
public record CreateChannelRequest(string Name, string? Topic = null, bool IsPublic = true, string? Password = null);
public record CreateChannelRequest(
string Name,
string? Topic = null,
bool IsPublic = true,
string? Password = null,
string? EncryptionSalt = null,
string? WrappedRoomKey = null);
/// <summary>
/// Public crypto metadata for a channel — enough for a client to derive its join
/// credential from a passphrase. Never includes the wrapped room key.
/// </summary>
public record ChannelCryptoDto(bool IsEncrypted, string? EncryptionSalt);
/// <summary>
/// Passphrase change for an encrypted channel: the client proves knowledge of the old
/// passphrase (old auth key), then supplies the re-wrapped room key under the new one.
/// </summary>
public record RekeyChannelRequest(
string OldPassword,
string NewPassword,
string NewEncryptionSalt,
string NewWrappedRoomKey);
public record UpdateTopicRequest(string? Topic);
public record SendUrlRequest(string Url);
public record JoinChannelResult(bool Success, List<MessageDto> History, string? Error = null, bool PasswordRequired = false);
public record JoinChannelResult(
bool Success,
List<MessageDto> History,
string? Error = null,
bool PasswordRequired = false,
string? EncryptionSalt = null,
string? WrappedRoomKey = null);
public record EmbedDto(
string? SiteName,
+5 -1
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -6,4 +6,8 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
</Project>
+6
View File
@@ -7,6 +7,12 @@ public class Channel
public string? Topic { get; set; }
public bool IsPublic { get; set; } = true;
public string? PasswordHash { get; set; }
// End-to-end encryption envelope (client-generated; server cannot decrypt room content).
// EncryptionSalt: PBKDF2 salt for passphrase-derived keys. WrappedRoomKey: the room
// content key encrypted under the passphrase-derived key-encryption key.
public string? EncryptionSalt { get; set; }
public string? WrappedRoomKey { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public Guid CreatedByUserId { get; set; }
+132
View File
@@ -0,0 +1,132 @@
using System.Security.Cryptography;
using System.Text;
namespace EchoHub.Core.Security;
/// <summary>
/// Client-side envelope encryption for private (end-to-end encrypted) channels.
///
/// Design: at creation the client generates a random 256-bit room content key (RCK)
/// that encrypts all room content. The RCK is stored on the server *wrapped*
/// (AES-GCM encrypted) by a key derived from the passphrase, next to a BCrypt hash
/// of a separately derived auth key used as the join gate. The passphrase, the
/// key-encryption key, and the RCK never leave the client, so the server can gate
/// joins and count/measure content without being able to read it. Changing the
/// passphrase only re-wraps the RCK — history is never re-encrypted.
///
/// Derivation: PBKDF2-SHA256(passphrase, salt, 210000 iterations) → 64 bytes;
/// first 32 bytes are the auth key (sent to the server as lowercase hex),
/// last 32 bytes are the key-encryption key (never sent).
/// </summary>
public static class RoomCrypto
{
public const string CiphertextPrefix = "$RC1$";
private const int Pbkdf2Iterations = 210_000;
private const int SaltSizeBytes = 16;
private const int KeySizeBytes = 32;
private const int NonceSizeBytes = 12;
private const int TagSizeBytes = 16;
public sealed record DerivedKeys(string AuthKeyHex, byte[] KeyEncryptionKey);
public static byte[] GenerateSalt() => RandomNumberGenerator.GetBytes(SaltSizeBytes);
public static byte[] GenerateRoomKey() => RandomNumberGenerator.GetBytes(KeySizeBytes);
/// <summary>
/// Derives the auth key (join gate credential) and key-encryption key from a passphrase.
/// </summary>
public static DerivedKeys DeriveKeys(string passphrase, byte[] salt)
{
var okm = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(passphrase), salt, Pbkdf2Iterations,
HashAlgorithmName.SHA256, KeySizeBytes * 2);
var authKey = Convert.ToHexString(okm.AsSpan(0, KeySizeBytes)).ToLowerInvariant();
var kek = okm[KeySizeBytes..];
CryptographicOperations.ZeroMemory(okm.AsSpan(0, KeySizeBytes));
return new DerivedKeys(authKey, kek);
}
/// <summary>Encrypts UTF-8 text with the room key. Output: $RC1$base64(nonce||tag||ciphertext).</summary>
public static string EncryptText(string plaintext, byte[] key) =>
CiphertextPrefix + Convert.ToBase64String(EncryptBytes(Encoding.UTF8.GetBytes(plaintext), key));
/// <summary>
/// Decrypts text produced by <see cref="EncryptText"/>. Returns false when the input
/// is not room ciphertext or the key does not match.
/// </summary>
public static bool TryDecryptText(string content, byte[] key, out string plaintext)
{
plaintext = string.Empty;
if (!IsRoomCiphertext(content))
return false;
try
{
var blob = Convert.FromBase64String(content[CiphertextPrefix.Length..]);
plaintext = Encoding.UTF8.GetString(DecryptBytes(blob, key));
return true;
}
catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException)
{
return false;
}
}
public static bool IsRoomCiphertext(string? content) =>
content is not null && content.StartsWith(CiphertextPrefix, StringComparison.Ordinal);
/// <summary>Encrypts a binary blob (file contents) with the room key: nonce||tag||ciphertext.</summary>
public static byte[] EncryptBytes(byte[] plaintext, byte[] key)
{
var nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes);
var ciphertext = new byte[plaintext.Length];
var tag = new byte[TagSizeBytes];
using var aes = new AesGcm(key, TagSizeBytes);
aes.Encrypt(nonce, plaintext, ciphertext, tag);
var blob = new byte[NonceSizeBytes + TagSizeBytes + ciphertext.Length];
nonce.CopyTo(blob, 0);
tag.CopyTo(blob, NonceSizeBytes);
ciphertext.CopyTo(blob, NonceSizeBytes + TagSizeBytes);
return blob;
}
/// <summary>Decrypts a blob produced by <see cref="EncryptBytes"/>. Throws <see cref="CryptographicException"/> on key mismatch.</summary>
public static byte[] DecryptBytes(byte[] blob, byte[] key)
{
if (blob.Length < NonceSizeBytes + TagSizeBytes)
throw new CryptographicException("Ciphertext blob is too short.");
var nonce = blob.AsSpan(0, NonceSizeBytes);
var tag = blob.AsSpan(NonceSizeBytes, TagSizeBytes);
var ciphertext = blob.AsSpan(NonceSizeBytes + TagSizeBytes);
var plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(key, TagSizeBytes);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return plaintext;
}
/// <summary>Wraps the room content key under the key-encryption key for server storage.</summary>
public static string WrapRoomKey(byte[] roomKey, byte[] kek) =>
Convert.ToBase64String(EncryptBytes(roomKey, kek));
/// <summary>Unwraps the stored room content key. Returns false when the KEK (passphrase) is wrong.</summary>
public static bool TryUnwrapRoomKey(string wrappedRoomKey, byte[] kek, out byte[] roomKey)
{
roomKey = [];
try
{
roomKey = DecryptBytes(Convert.FromBase64String(wrappedRoomKey), kek);
return roomKey.Length == KeySizeBytes;
}
catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException)
{
return false;
}
}
}
@@ -1,4 +1,4 @@
namespace EchoHub.Server.Services;
namespace EchoHub.Core.Services;
public static class FileValidationHelper
{
@@ -4,7 +4,7 @@ using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace EchoHub.Server.Services;
namespace EchoHub.Core.Services;
public class ImageToAsciiService
{