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
+251 -7
View File
@@ -8,6 +8,8 @@ using EchoHub.Client.UI.Dialogs;
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Core.Security;
using EchoHub.Core.Services;
using Serilog;
using Terminal.Gui.App;
using Terminal.Gui.Views;
@@ -88,6 +90,7 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
_mainWindow.OnImageSaveRequested += HandleImageSaveRequested;
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
_mainWindow.OnUserProfileRequested += HandleViewProfile;
@@ -109,6 +112,7 @@ public sealed class AppOrchestrator : IDisposable
_commandHandler.OnOpenProfile += HandleCmdOpenProfile;
_commandHandler.OnOpenServers += HandleCmdOpenServers;
_commandHandler.OnJoinChannel += HandleCmdJoinChannel;
_commandHandler.OnChangeRoomPassword += HandleCmdChangeRoomPassword;
_commandHandler.OnLeaveChannel += HandleCmdLeaveChannel;
_commandHandler.OnSetTopic += HandleCmdSetTopic;
_commandHandler.OnListUsers += HandleCmdListUsers;
@@ -167,11 +171,24 @@ public sealed class AppOrchestrator : IDisposable
try
{
var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey);
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
&& (uri.Scheme == "http" || uri.Scheme == "https"))
{
if (hasRoomKey)
{
InvokeUI(() => _mainWindow.ShowError(
"Sending by URL isn't available in encrypted channels — download the file and /send it instead."));
return;
}
await _conn.Api!.SendUrlAsync(channel, target, size);
}
else if (hasRoomKey)
{
await UploadEncryptedFileAsync(channel, target, size, roomKey);
}
else
{
await using var stream = File.OpenRead(target);
@@ -186,6 +203,46 @@ public sealed class AppOrchestrator : IDisposable
}
}
/// <summary>
/// Upload into an end-to-end encrypted channel: the blob is encrypted with the room
/// key before it leaves this machine, and for images the ASCII preview is rendered
/// locally and sent room-encrypted — the server never sees image or file contents.
/// </summary>
private async Task UploadEncryptedFileAsync(string channel, string path, string? size, byte[] roomKey)
{
var fileName = Path.GetFileName(path);
var bytes = await File.ReadAllBytesAsync(path);
string declaredType;
string plainContent;
using (var ms = new MemoryStream(bytes))
{
if (FileValidationHelper.IsValidImage(ms))
{
declaredType = "image";
var (w, h) = ImageToAsciiService.GetDimensions(size);
ms.Position = 0;
plainContent = new ImageToAsciiService().ConvertToAscii(ms, w, h);
}
else if (FileValidationHelper.IsAudioFile(fileName))
{
declaredType = "audio";
plainContent = fileName;
}
else
{
declaredType = "file";
plainContent = fileName;
}
}
var encryptedContent = RoomCrypto.EncryptText(plainContent, roomKey);
var encryptedBlob = RoomCrypto.EncryptBytes(bytes, roomKey);
await using var blobStream = new MemoryStream(encryptedBlob);
await _conn.Api!.UploadFileAsync(channel, blobStream, fileName, size, declaredType, encryptedContent);
}
private async Task HandleCmdSetAvatar(string target)
{
if (!_conn.IsAuthenticated) return;
@@ -241,16 +298,51 @@ public sealed class AppOrchestrator : IDisposable
/// <summary>
/// Joins a channel, prompting for a password when the server requires one and
/// re-prompting on a wrong password. Returns the channel history, or null if
/// the user cancelled the prompt.
/// re-prompting on a wrong password. For end-to-end encrypted channels the typed
/// passphrase never goes to the server — a PBKDF2-derived auth key is sent instead,
/// and the room content key is unwrapped locally. Returns the channel history,
/// or null if the user cancelled the prompt.
/// </summary>
private async Task<List<MessageDto>?> JoinChannelWithPasswordPromptAsync(string channelName, string? password)
{
ChannelCryptoDto? crypto = null;
try
{
crypto = await _conn.Api!.GetChannelCryptoAsync(channelName);
}
catch (Exception ex)
{
Log.Debug(ex, "Crypto metadata unavailable for {Channel}", channelName);
}
while (true)
{
byte[]? kek = null;
var wirePassword = password;
if (password is not null && crypto is { IsEncrypted: true, EncryptionSalt: not null })
{
var derived = RoomCrypto.DeriveKeys(password, Convert.FromBase64String(crypto.EncryptionSalt));
wirePassword = derived.AuthKeyHex;
kek = derived.KeyEncryptionKey;
}
try
{
return await _conn.JoinChannelAsync(channelName, password);
var outcome = await _conn.JoinChannelAsync(channelName, wirePassword);
if (outcome.WrappedRoomKey is not null && !_conn.RoomKeys.HasKey(channelName))
{
if (kek is not null && RoomCrypto.TryUnwrapRoomKey(outcome.WrappedRoomKey, kek, out var roomKey))
{
_conn.RoomKeys.StoreKey(channelName, roomKey);
// Re-fetch so history decrypts with the now-available room key
return await _conn.GetHistoryAsync(channelName);
}
return await UnlockRoomKeyAsync(channelName, outcome);
}
return outcome.History;
}
catch (ChannelPasswordRequiredException ex)
{
@@ -264,6 +356,85 @@ public sealed class AppOrchestrator : IDisposable
}
}
/// <summary>
/// Member of an encrypted channel without a cached room key (e.g. a new device):
/// prompt for the passphrase until the room key unwraps or the user gives up.
/// </summary>
private async Task<List<MessageDto>?> UnlockRoomKeyAsync(string channelName, JoinOutcome outcome)
{
if (outcome.EncryptionSalt is null || outcome.WrappedRoomKey is null)
return outcome.History;
var salt = Convert.FromBase64String(outcome.EncryptionSalt);
var message = "Enter the passphrase to unlock messages.";
while (true)
{
var prompt = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
var promptMessage = message;
InvokeUI(() => prompt.SetResult(ChannelPasswordDialog.Show(_app, channelName, promptMessage)));
var passphrase = await prompt.Task;
if (passphrase is null)
return outcome.History; // stays locked; placeholders render instead of content
var derived = RoomCrypto.DeriveKeys(passphrase, salt);
if (RoomCrypto.TryUnwrapRoomKey(outcome.WrappedRoomKey, derived.KeyEncryptionKey, out var roomKey))
{
_conn.RoomKeys.StoreKey(channelName, roomKey);
return await _conn.GetHistoryAsync(channelName);
}
message = "Wrong passphrase — try again.";
}
}
/// <summary>
/// Changes the current encrypted channel's passphrase: re-derives the join credential
/// and re-wraps the cached room content key under the new passphrase. History is
/// never re-encrypted — the room key itself doesn't change.
/// </summary>
private async Task HandleCmdChangeRoomPassword(string oldPassphrase, string newPassphrase)
{
if (!_conn.IsAuthenticated || !_conn.IsConnected) return;
var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel)) return;
try
{
var crypto = await _conn.Api!.GetChannelCryptoAsync(channel);
if (crypto is not { IsEncrypted: true } || crypto.EncryptionSalt is null)
{
InvokeUI(() => _mainWindow.ShowError($"#{channel} is not an end-to-end encrypted channel."));
return;
}
if (!_conn.RoomKeys.TryGetKey(channel, out var roomKey))
{
InvokeUI(() => _mainWindow.ShowError("Unlock this channel first (rejoin it with its passphrase), then retry."));
return;
}
var oldDerived = RoomCrypto.DeriveKeys(oldPassphrase, Convert.FromBase64String(crypto.EncryptionSalt));
var newSalt = RoomCrypto.GenerateSalt();
var newDerived = RoomCrypto.DeriveKeys(newPassphrase, newSalt);
await _conn.Api!.RekeyChannelAsync(channel, new RekeyChannelRequest(
oldDerived.AuthKeyHex,
newDerived.AuthKeyHex,
Convert.ToBase64String(newSalt),
RoomCrypto.WrapRoomKey(roomKey, newDerived.KeyEncryptionKey)));
InvokeUI(() => _messageManager.AddSystemMessage(channel,
"Passphrase changed. History stays readable; new members and new devices need the new passphrase."));
}
catch (Exception ex)
{
InvokeUI(() => _mainWindow.ShowError($"Passphrase change failed: {ex.Message}"));
}
}
private async Task HandleCmdLeaveChannel()
{
if (!_conn.IsConnected) return;
@@ -1019,10 +1190,35 @@ public sealed class AppOrchestrator : IDisposable
RunAsync(async () =>
{
var channel = await _conn.Api!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic, result.Password);
// Password rooms are end-to-end encrypted: derive the join credential and
// wrap a fresh room content key locally — the passphrase never leaves here.
string? wirePassword = null, saltB64 = null, wrappedKey = null;
byte[]? roomKey = null;
if (result.Password is not null)
{
if (result.Password.Length < ValidationConstants.MinChannelPasswordLength)
{
InvokeUI(() => _mainWindow.ShowError(
$"Channel password must be at least {ValidationConstants.MinChannelPasswordLength} characters."));
return;
}
var salt = RoomCrypto.GenerateSalt();
var derived = RoomCrypto.DeriveKeys(result.Password, salt);
roomKey = RoomCrypto.GenerateRoomKey();
wirePassword = derived.AuthKeyHex;
saltB64 = Convert.ToBase64String(salt);
wrappedKey = RoomCrypto.WrapRoomKey(roomKey, derived.KeyEncryptionKey);
}
var channel = await _conn.Api!.CreateChannelAsync(
result.Name, result.Topic, result.IsPublic, wirePassword, saltB64, wrappedKey);
if (channel is null) return;
var history = await _conn.JoinChannelAsync(channel.Name);
if (roomKey is not null)
_conn.RoomKeys.StoreKey(channel.Name, roomKey);
var history = (await _conn.JoinChannelAsync(channel.Name)).History;
InvokeUI(() =>
{
@@ -1084,11 +1280,59 @@ public sealed class AppOrchestrator : IDisposable
RunAsync(async () =>
{
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
InvokeUI(() => AudioPlayerDialog.Show(_app, _audioPlayback, tempPath, fileName));
}, "Failed to play audio");
}
/// <summary>
/// Downloads an attachment to a temp file, decrypting it locally when the current
/// channel is end-to-end encrypted (the server stores those blobs as ciphertext).
/// </summary>
private async Task<string> DownloadAttachmentAsync(string attachmentUrl, string fileName)
{
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
var channel = _mainWindow.CurrentChannel;
if (!string.IsNullOrEmpty(channel) && _conn.RoomKeys.TryGetKey(channel, out var roomKey))
{
try
{
var blob = await File.ReadAllBytesAsync(tempPath);
await File.WriteAllBytesAsync(tempPath, RoomCrypto.DecryptBytes(blob, roomKey));
}
catch (Exception ex)
{
Log.Warning(ex, "Attachment {File} did not decrypt with the room key — keeping raw bytes", fileName);
}
}
return tempPath;
}
private void HandleImageSaveRequested(string attachmentUrl, string fileName)
{
if (!_conn.IsAuthenticated) return;
RunAsync(async () =>
{
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
var downloads = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
Directory.CreateDirectory(downloads);
var stem = Path.GetFileNameWithoutExtension(fileName);
var ext = Path.GetExtension(fileName);
var destination = Path.Combine(downloads, fileName);
for (var i = 1; File.Exists(destination); i++)
destination = Path.Combine(downloads, $"{stem} ({i}){ext}");
File.Move(tempPath, destination);
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Image saved to: {destination}"));
}, "Failed to save image");
}
/// <summary>
/// File extensions considered safe to open with the system default application.
/// Everything else is downloaded only — never auto-opened via UseShellExecute.
@@ -1106,7 +1350,7 @@ public sealed class AppOrchestrator : IDisposable
RunAsync(async () =>
{
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
var ext = Path.GetExtension(fileName);
if (SafeOpenExtensions.Contains(ext))
@@ -14,6 +14,7 @@ public class CommandHandler
public event Func<string?, Task>? OnOpenProfile;
public event Func<Task>? OnOpenServers;
public event Func<string, string?, Task>? OnJoinChannel;
public event Func<string, string, Task>? OnChangeRoomPassword;
public event Func<Task>? OnLeaveChannel;
public event Func<string, Task>? OnSetTopic;
public event Func<Task>? OnListUsers;
@@ -51,6 +52,7 @@ public class CommandHandler
"avatar" => await HandleAvatar(args),
"servers" => await HandleServers(),
"join" => await HandleJoin(args),
"passwd" => await HandlePasswd(args),
"leave" => await HandleLeave(),
"topic" => await HandleTopic(args),
"users" => await HandleUsers(),
@@ -204,6 +206,20 @@ public class CommandHandler
return new CommandResult(true);
}
private async Task<CommandResult> HandlePasswd(string args)
{
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length != 2)
return new CommandResult(true, "Usage: /passwd <old passphrase> <new passphrase> — changes the current encrypted channel's passphrase", IsError: true);
if (parts[1].Length < 3)
return new CommandResult(true, "New passphrase must be at least 3 characters.", IsError: true);
if (OnChangeRoomPassword is not null)
await OnChangeRoomPassword(parts[0], parts[1]);
return new CommandResult(true);
}
private async Task<CommandResult> HandleLeave()
{
if (OnLeaveChannel is not null)
@@ -347,6 +363,7 @@ public class CommandHandler
/profile [username] - View a profile
/servers - Open saved servers
/join <channel> [password] - Join a channel (password if protected)
/passwd <old> <new> - Change current encrypted channel's passphrase
/leave - Leave current channel
/topic <text> - Set channel topic
/users - List online users
@@ -23,6 +23,13 @@ public class SavedServer
public string? RefreshToken { get; set; }
public bool RememberMe { get; set; }
public DateTimeOffset LastConnected { get; set; }
/// <summary>
/// Cached room content keys for end-to-end encrypted channels on this server,
/// keyed by channel name (base64). Like RefreshToken, these live only on the
/// user's machine — the server never sees them.
/// </summary>
public Dictionary<string, string> ChannelKeys { get; set; } = [];
}
public class AccountPreset
+35 -3
View File
@@ -185,7 +185,8 @@ public sealed class ApiClient : IDisposable
return result?.AvatarAscii;
}
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null)
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null,
string? declaredType = null, string? encryptedContent = null)
{
EnsureAuthenticated();
using var content = new MultipartFormDataContent();
@@ -193,6 +194,13 @@ public sealed class ApiClient : IDisposable
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
content.Add(streamContent, "file", fileName);
// E2E channels: the blob is ciphertext, so the client declares the type and
// supplies the room-encrypted message content the server can't produce.
if (declaredType is not null)
content.Add(new StringContent(declaredType), "type");
if (encryptedContent is not null)
content.Add(new StringContent(encryptedContent), "content");
var sizeQuery = size is not null ? $"?size={size}" : "";
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content));
@@ -228,16 +236,40 @@ public sealed class ApiClient : IDisposable
return tempPath;
}
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true, string? password = null)
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true,
string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null)
{
EnsureAuthenticated();
var request = new CreateChannelRequest(name, topic, isPublic, password);
var request = new CreateChannelRequest(name, topic, isPublic, password, encryptionSalt, wrappedRoomKey);
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync("/api/channels", request));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<ChannelDto>();
}
/// <summary>
/// Fetches a channel's public crypto metadata (whether it's E2E-encrypted and its
/// key-derivation salt). Returns null when the channel doesn't exist.
/// </summary>
public async Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName)
{
EnsureAuthenticated();
using var response = await AuthenticatedGetAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/crypto");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<ChannelCryptoDto>();
}
public async Task<ChannelDto?> RekeyChannelAsync(string channelName, RekeyChannelRequest request)
{
EnsureAuthenticated();
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/rekey", request));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<ChannelDto>();
}
public async Task<ChannelDto?> UpdateChannelTopicAsync(string channelName, string? topic)
{
EnsureAuthenticated();
@@ -24,6 +24,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
private EchoHubConnection? _connection;
private ApiClient? _apiClient;
private readonly ClientEncryptionService _encryption = new();
private readonly RoomKeyStore _roomKeys = new();
private readonly HashSet<string> _joinedChannels = [];
// ── Properties ────────────────────────────────────────────────────────
@@ -31,6 +32,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
public bool IsConnected => _connection?.IsConnected == true;
public bool IsAuthenticated => _apiClient is not null;
public ApiClient? Api => _apiClient;
public RoomKeyStore RoomKeys => _roomKeys;
// ── Events (forwarded from SignalR) ───────────────────────────────────
@@ -101,7 +103,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
if (_connection is not null)
await _connection.DisposeAsync();
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption);
_roomKeys.LoadForServer(info.ServerUrl);
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption, _roomKeys);
WireConnectionEvents(_connection);
await _connection.ConnectAsync();
@@ -156,6 +159,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
_apiClient?.Dispose();
_apiClient = null;
_joinedChannels.Clear();
_roomKeys.Clear();
}
/// <summary>
@@ -169,14 +173,14 @@ internal sealed class ConnectionManager : IAsyncDisposable
// ── Channel Operations ────────────────────────────────────────────────
public async Task<List<MessageDto>> JoinChannelAsync(string channelName, string? password = null)
public async Task<JoinOutcome> JoinChannelAsync(string channelName, string? password = null)
{
if (_connection is null) throw new InvalidOperationException("Not connected");
try
{
var history = await _connection.JoinChannelAsync(channelName, password);
var outcome = await _connection.JoinChannelAsync(channelName, password);
_joinedChannels.Add(channelName);
return history;
return outcome;
}
catch (ChannelPasswordRequiredException)
{
@@ -1,10 +1,17 @@
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Core.Security;
using Microsoft.AspNetCore.SignalR.Client;
namespace EchoHub.Client.Services;
/// <summary>
/// Result of joining a channel: decrypted history plus, for end-to-end encrypted
/// channels, the key envelope needed to unlock the room content key.
/// </summary>
public sealed record JoinOutcome(List<MessageDto> History, string? EncryptionSalt, string? WrappedRoomKey);
/// <summary>
/// Thrown when joining a channel fails because a password is required or incorrect.
/// The UI catches this to prompt the user and retry.
@@ -21,8 +28,12 @@ public sealed class ChannelPasswordRequiredException : Exception
public sealed class EchoHubConnection : IAsyncDisposable
{
public const string LockedMessagePlaceholder =
"[encrypted — rejoin this channel with its passphrase to unlock]";
private readonly HubConnection _connection;
private readonly ClientEncryptionService _encryption;
private readonly RoomKeyStore _roomKeys;
public event Action<MessageDto>? OnMessageReceived;
public event Action<string, string, UserPresenceDto?>? OnUserJoined;
@@ -40,9 +51,10 @@ public sealed class EchoHubConnection : IAsyncDisposable
public bool IsConnected => _connection.State == HubConnectionState.Connected;
public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption)
public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption, RoomKeyStore roomKeys)
{
_encryption = encryption;
_roomKeys = roomKeys;
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
_connection = new HubConnectionBuilder()
@@ -79,9 +91,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
{
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
{
// Decrypt message content received from server
var decrypted = message with { Content = _encryption.Decrypt(message.Content) };
OnMessageReceived?.Invoke(decrypted);
OnMessageReceived?.Invoke(DecryptMessage(message));
});
_connection.On<string, string, UserPresenceDto?>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username, presence) =>
@@ -148,7 +158,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
OnConnectionStateChanged?.Invoke("Disconnected");
}
public async Task<List<MessageDto>> JoinChannelAsync(string channelName, string? password = null)
public async Task<JoinOutcome> JoinChannelAsync(string channelName, string? password = null)
{
var result = await _connection.InvokeAsync<JoinChannelResult>("JoinChannel", channelName, password);
if (!result.Success)
@@ -157,7 +167,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
throw new ChannelPasswordRequiredException(channelName, result.Error ?? "Channel is password protected.");
throw new InvalidOperationException(result.Error ?? "Failed to join channel.");
}
return DecryptMessages(result.History);
return new JoinOutcome(DecryptMessages(result.History), result.EncryptionSalt, result.WrappedRoomKey);
}
public async Task LeaveChannelAsync(string channelName)
@@ -167,7 +177,10 @@ public sealed class EchoHubConnection : IAsyncDisposable
public async Task SendMessageAsync(string channelName, string content)
{
// Encrypt content before sending to server
// Room layer first (end-to-end, server can't read), then transport encryption
if (_roomKeys.TryGetKey(channelName, out var roomKey))
content = RoomCrypto.EncryptText(content, roomKey);
var encrypted = _encryption.Encrypt(content);
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
}
@@ -190,7 +203,32 @@ public sealed class EchoHubConnection : IAsyncDisposable
private List<MessageDto> DecryptMessages(List<MessageDto> messages)
{
return messages.Select(m => m with { Content = _encryption.Decrypt(m.Content) }).ToList();
return messages.Select(DecryptMessage).ToList();
}
/// <summary>
/// Strips the transport encryption, then the room layer for E2E channels.
/// Without the room key the content is replaced by a locked placeholder —
/// re-fetch history after unlocking to render it.
/// </summary>
private MessageDto DecryptMessage(MessageDto message)
{
var content = _encryption.Decrypt(message.Content);
if (RoomCrypto.IsRoomCiphertext(content))
{
if (_roomKeys.TryGetKey(message.ChannelName, out var roomKey)
&& RoomCrypto.TryDecryptText(content, roomKey, out var plaintext))
{
content = plaintext;
}
else
{
content = LockedMessagePlaceholder;
}
}
return message with { Content = content };
}
public async ValueTask DisposeAsync()
+109
View File
@@ -0,0 +1,109 @@
using EchoHub.Client.Config;
using Serilog;
namespace EchoHub.Client.Services;
/// <summary>
/// Holds room content keys for end-to-end encrypted channels: in-memory for the
/// active session, persisted per-server in the client config (like saved sessions)
/// so users don't retype the passphrase every launch. Keys never leave this machine.
/// </summary>
public sealed class RoomKeyStore
{
private readonly Dictionary<string, byte[]> _keys = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _lock = new();
private string? _serverUrl;
/// <summary>Binds the store to a server and loads that server's cached keys from config.</summary>
public void LoadForServer(string serverUrl)
{
lock (_lock)
{
_serverUrl = serverUrl;
_keys.Clear();
var server = FindServer(ConfigManager.Load(), serverUrl);
if (server is null) return;
foreach (var (channel, base64) in server.ChannelKeys)
{
try
{
_keys[channel] = Convert.FromBase64String(base64);
}
catch (FormatException)
{
Log.Warning("Ignoring malformed cached room key for #{Channel}", channel);
}
}
}
}
public bool TryGetKey(string channelName, out byte[] key)
{
lock (_lock)
{
if (_keys.TryGetValue(channelName, out var k))
{
key = k;
return true;
}
}
key = [];
return false;
}
public bool HasKey(string channelName) => TryGetKey(channelName, out _);
/// <summary>Stores a key for the session and persists it to the server's config entry.</summary>
public void StoreKey(string channelName, byte[] key)
{
lock (_lock)
{
_keys[channelName] = key;
Persist(server => server.ChannelKeys[channelName] = Convert.ToBase64String(key));
}
}
public void RemoveKey(string channelName)
{
lock (_lock)
{
_keys.Remove(channelName);
Persist(server => server.ChannelKeys.Remove(channelName));
}
}
public void Clear()
{
lock (_lock)
{
_keys.Clear();
_serverUrl = null;
}
}
private void Persist(Action<SavedServer> mutate)
{
if (_serverUrl is null) return;
try
{
var config = ConfigManager.Load();
var server = FindServer(config, _serverUrl);
if (server is null) return; // server not saved yet — key stays in-memory only
mutate(server);
ConfigManager.Save(config);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to persist room key cache");
}
}
private static SavedServer? FindServer(ClientConfig config, string url) =>
config.SavedServers.FirstOrDefault(s =>
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
}
@@ -255,6 +255,22 @@ public sealed class ChatMessageManager
lines.Add(new ChatLine($" {trimmed}"));
}
}
// Clickable action to download the original image below the ASCII art
if (message.AttachmentUrl is not null)
{
var imageName = message.AttachmentFileName ?? "image";
var imageSize = FormatFileSize(message.AttachmentFileSize);
var saveLine = new ChatLine(new List<ChatSegment>
{
new(" ", null),
new($"[↓ save original] {imageName} [{imageSize}]", ChatColors.FileAttr),
});
saveLine.AttachmentUrl = message.AttachmentUrl;
saveLine.AttachmentFileName = imageName;
saveLine.Type = MessageType.Image;
lines.Add(saveLine);
}
break;
case MessageType.Audio:
+13 -1
View File
@@ -60,7 +60,7 @@ public sealed partial class MainWindow : Runnable
private static readonly string[] SlashCommands =
[
"/status", "/nick", "/color", "/theme", "/send",
"/avatar", "/profile", "/servers", "/join", "/leave",
"/avatar", "/profile", "/servers", "/join", "/passwd", "/leave",
"/topic", "/users", "/kick", "/ban", "/unban",
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
];
@@ -154,6 +154,11 @@ public sealed partial class MainWindow : Runnable
/// </summary>
public event Action<string, string>? OnFileDownloadRequested;
/// <summary>
/// Fired when the user activates an image's "[save original]" line. Parameters: attachmentUrl, fileName.
/// </summary>
public event Action<string, string>? OnImageSaveRequested;
/// <summary>
/// Fired when the user activates a username (in userlist or message). Parameter is the username.
/// </summary>
@@ -466,6 +471,13 @@ public sealed partial class MainWindow : Runnable
e.Handled = true;
return;
}
if (line.Type == MessageType.Image)
{
OnImageSaveRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
e.Handled = true;
return;
}
}
var lineText = line.ToString();