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
@@ -1,6 +1,7 @@
using System.Security.Claims;
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.Services;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
@@ -65,7 +66,8 @@ public class ChannelsController : ControllerBase
return Unauthorized(new ErrorResponse("Authentication required."));
var result = await _channelService.CreateChannelAsync(
Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic, request.Password);
Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic, request.Password,
request.EncryptionSalt, request.WrappedRoomKey);
if (!result.IsSuccess)
return MapChannelError(result);
@@ -75,6 +77,43 @@ public class ChannelsController : ControllerBase
return Created($"/api/channels/{result.Channel.Name}", result.Channel);
}
/// <summary>
/// Public crypto metadata for a channel: whether it is end-to-end encrypted and the
/// PBKDF2 salt clients need to derive their join credential. Never returns the
/// wrapped room key — that is only handed out after a successful join.
/// </summary>
[HttpGet("{channel}/crypto")]
public async Task<IActionResult> GetChannelCrypto(string channel)
{
var crypto = await _channelService.GetChannelCryptoAsync(channel);
if (crypto is null)
return NotFound(new ErrorResponse($"Channel '{channel}' does not exist."));
return Ok(crypto);
}
/// <summary>
/// Changes an encrypted channel's passphrase by re-wrapping its room key.
/// The caller proves knowledge of the old passphrase via the old auth key;
/// history is never re-encrypted (the room content key does not change).
/// </summary>
[HttpPost("{channel}/rekey")]
public async Task<IActionResult> RekeyChannel(string channel, [FromBody] RekeyChannelRequest request)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var result = await _channelService.RekeyChannelAsync(
Guid.Parse(userIdClaim), channel,
request.OldPassword, request.NewPassword,
request.NewEncryptionSalt, request.NewWrappedRoomKey);
if (!result.IsSuccess)
return MapChannelError(result);
return Ok(result.Channel);
}
[HttpPut("{channel}/topic")]
public async Task<IActionResult> UpdateTopic(string channel, [FromBody] UpdateTopicRequest request)
{
@@ -131,34 +170,68 @@ public class ChannelsController : ControllerBase
var file = Request.Form.Files[0];
// Detect file type early so we can apply the correct size limit
using var stream = file.OpenReadStream();
var isImage = FileValidationHelper.IsValidImage(stream);
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
var maxSize = isImage ? HubConstants.MaxImageSizeBytes
: isAudio ? HubConstants.MaxAudioFileSizeBytes
: HubConstants.MaxFileSizeBytes;
if (file.Length > maxSize)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB."));
var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
var messageType = isImage ? MessageType.Image
: isAudio ? MessageType.Audio
: MessageType.File;
MessageType messageType;
string content;
string fileId;
if (isImage)
if (channelDto.IsEncrypted)
{
var (w, h) = ImageToAsciiService.GetDimensions(size);
using var imageStream = System.IO.File.OpenRead(filePath);
content = _asciiService.ConvertToAscii(imageStream, w, h);
// E2E-encrypted channel: the blob is ciphertext the server cannot inspect.
// The client declares the type and supplies pre-rendered, room-encrypted
// content (ASCII art for images, encrypted filename otherwise).
messageType = Request.Form["type"].ToString().ToLowerInvariant() switch
{
"image" => MessageType.Image,
"audio" => MessageType.Audio,
_ => MessageType.File,
};
var declaredMax = messageType switch
{
MessageType.Image => HubConstants.MaxImageSizeBytes,
MessageType.Audio => HubConstants.MaxAudioFileSizeBytes,
_ => HubConstants.MaxFileSizeBytes,
};
if (file.Length > declaredMax)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {declaredMax / (1024 * 1024)} MB."));
var clientContent = Request.Form["content"].ToString();
content = string.IsNullOrEmpty(clientContent) ? file.FileName : clientContent;
using var encryptedStream = file.OpenReadStream();
(fileId, _) = await _fileStorage.SaveFileAsync(encryptedStream, file.FileName);
}
else
{
content = file.FileName;
// Detect file type early so we can apply the correct size limit
using var stream = file.OpenReadStream();
var isImage = FileValidationHelper.IsValidImage(stream);
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
var maxSize = isImage ? HubConstants.MaxImageSizeBytes
: isAudio ? HubConstants.MaxAudioFileSizeBytes
: HubConstants.MaxFileSizeBytes;
if (file.Length > maxSize)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB."));
string filePath;
(fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
messageType = isImage ? MessageType.Image
: isAudio ? MessageType.Audio
: MessageType.File;
if (isImage)
{
var (w, h) = ImageToAsciiService.GetDimensions(size);
using var imageStream = System.IO.File.OpenRead(filePath);
content = _asciiService.ConvertToAscii(imageStream, w, h);
}
else
{
content = file.FileName;
}
}
var attachmentUrl = $"/api/files/{fileId}";
@@ -219,6 +292,10 @@ public class ChannelsController : ControllerBase
if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (channelDto.IsEncrypted)
return BadRequest(new ErrorResponse(
"Sending images by URL is not available in end-to-end encrypted channels — download the image and /send the file instead."));
if (string.IsNullOrWhiteSpace(request.Url))
return BadRequest(new ErrorResponse("URL is required."));
@@ -1,6 +1,7 @@
using System.Security.Claims;
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.Services;
using EchoHub.Core.DTOs;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
@@ -45,6 +45,8 @@ public class EchoHubDbContext : DbContext
entity.Property(c => c.Name).IsRequired().HasMaxLength(100);
entity.Property(c => c.Topic).HasMaxLength(500);
entity.Property(c => c.PasswordHash).HasMaxLength(100);
entity.Property(c => c.EncryptionSalt).HasMaxLength(64);
entity.Property(c => c.WrappedRoomKey).HasMaxLength(200);
entity.HasMany(c => c.Messages)
.WithOne(m => m.Channel)
@@ -0,0 +1,279 @@
// <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("20260716012917_AddChannelEncryptionEnvelope")]
partial class AddChannelEncryptionEnvelope
{
/// <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<string>("EncryptionSalt")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Topic")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("WrappedRoomKey")
.HasMaxLength(200)
.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<long?>("AttachmentFileSize")
.HasColumnType("INTEGER");
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,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddChannelEncryptionEnvelope : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "EncryptionSalt",
table: "Channels",
type: "TEXT",
maxLength: 64,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "WrappedRoomKey",
table: "Channels",
type: "TEXT",
maxLength: 200,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EncryptionSalt",
table: "Channels");
migrationBuilder.DropColumn(
name: "WrappedRoomKey",
table: "Channels");
}
}
}
@@ -29,6 +29,10 @@ namespace EchoHub.Server.Data.Migrations
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<string>("EncryptionSalt")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
@@ -45,6 +49,10 @@ namespace EchoHub.Server.Data.Migrations
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("WrappedRoomKey")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
+10 -2
View File
@@ -14,9 +14,12 @@ public class ChatHub : Hub<IEchoHubClient>
private readonly IChatService _chatService;
private readonly ILogger<ChatHub> _logger;
public ChatHub(IChatService chatService, ILogger<ChatHub> logger)
private readonly IChannelService _channelService;
public ChatHub(IChatService chatService, IChannelService channelService, ILogger<ChatHub> logger)
{
_chatService = chatService;
_channelService = channelService;
_logger = logger;
}
@@ -67,7 +70,12 @@ public class ChatHub : Hub<IEchoHubClient>
return new JoinChannelResult(false, [], error, passwordRequired);
await Groups.AddToGroupAsync(Context.ConnectionId, channelName.ToLowerInvariant().Trim());
return new JoinChannelResult(true, history);
// Members of encrypted channels receive the key envelope so they can unwrap
// the room content key with their passphrase (the server can't).
var (encryptionSalt, wrappedRoomKey) = await _channelService.GetChannelKeyEnvelopeAsync(channelName);
return new JoinChannelResult(true, history,
EncryptionSalt: encryptionSalt, WrappedRoomKey: wrappedRoomKey);
}
catch (Exception ex)
{
+1
View File
@@ -2,6 +2,7 @@ using System.Text;
using System.Threading.RateLimiting;
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.Services;
using EchoHub.Core.Models;
using EchoHub.Server.Auth;
using EchoHub.Server.Data;
+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(