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;
|
||||
|
||||
Reference in New Issue
Block a user