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,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)
|
||||
|
||||
Generated
+279
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user