diff --git a/src/Decho/Services/ConnectionService.cs b/src/Decho/Services/ConnectionService.cs index 6471256..c61add0 100644 --- a/src/Decho/Services/ConnectionService.cs +++ b/src/Decho/Services/ConnectionService.cs @@ -141,14 +141,24 @@ public sealed class ConnectionService : IDisposable } RemoveFromLeftChannels(serverUrl, channelName); + + // For E2EE channels where the key hasn't been stored yet, always do a real join + // to obtain the wrapped room key (TrackChannel returned false on re-selection, + // so the first branch above skipped the actual join). + bool encFlag = entry.Manager.RoomKeys.IsChannelEncrypted(channelName); + bool hasKeyFlag = entry.Manager.RoomKeys.HasKey(channelName); + + if (encFlag && !hasKeyFlag) + { + JoinOutcome outcome = await entry.Manager.JoinChannelAsync(channelName, password); + List history = outcome.History.Select(m => MessageModelFromDto(m, entry)).ToList(); + return new ChannelJoinResult(history, true, outcome.EncryptionSalt, outcome.WrappedRoomKey); + } + List existing = await entry.Manager.GetHistoryAsync(channelName); List hist = existing.Select(m => MessageModelFromDto(m, entry)).ToList(); - // Check if we have the key for an already-tracked encrypted channel - bool enc = entry.Manager.RoomKeys.IsChannelEncrypted(channelName); - bool hasK = entry.Manager.RoomKeys.HasKey(channelName); - - return new ChannelJoinResult(hist, enc && !hasK, null, null); + return new ChannelJoinResult(hist, encFlag && !hasKeyFlag, null, null); } public async Task UnlockRoomKeyAsync(string serverUrl, string channelName, string passphrase, string encryptionSalt, string wrappedRoomKey) @@ -163,8 +173,18 @@ public sealed class ConnectionService : IDisposable return new ChannelJoinResult([], false, null, null); } + if (string.IsNullOrEmpty(encryptionSalt)) + { + throw new InvalidOperationException("Encryption salt not available for this channel"); + } + + if (string.IsNullOrEmpty(wrappedRoomKey)) + { + throw new InvalidOperationException("Wrapped room key not available. Re-join the channel to obtain it."); + } + byte[] salt = Convert.FromBase64String(encryptionSalt); - var derived = RoomCrypto.DeriveKeys(passphrase, salt); + RoomCrypto.DerivedKeys derived = RoomCrypto.DeriveKeys(passphrase, salt); if (!entry.Manager.RoomKeys.TryStoreFromEnvelope(channelName, wrappedRoomKey, derived.KeyEncryptionKey)) { @@ -249,6 +269,36 @@ public sealed class ConnectionService : IDisposable entry.Manager.UntrackChannel(channelName); } + public async Task> GetChannelsAsync(string serverUrl) + { + if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) + { + return []; + } + + return await entry.ApiClient.GetChannelsAsync(); + } + + public async Task GetChannelCryptoAsync(string serverUrl, string channelName) + { + if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) + { + return null; + } + + return await entry.ApiClient.GetChannelCryptoAsync(channelName); + } + + public void MarkChannelEncrypted(string serverUrl, string channelName, bool isEncrypted) + { + if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) + { + return; + } + + entry.Manager.RoomKeys.MarkChannelEncrypted(channelName, isEncrypted); + } + public async Task KickUserAsync(string serverUrl, string username, string? reason) { if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) @@ -377,12 +427,12 @@ public sealed class ConnectionService : IDisposable string declaredKind; string? preview = null; - await using (var ms = new MemoryStream(bytes)) + await using (MemoryStream ms = new MemoryStream(bytes)) { if (FileValidationHelper.IsValidImage(ms)) { declaredKind = "image"; - var (w, h) = ImageToAsciiService.GetDimensions(size); + (int w, int h) = ImageToAsciiService.GetDimensions(size); ms.Position = 0; preview = RoomCrypto.EncryptText(new ImageToAsciiService().ConvertToAscii(ms, w, h), roomKey); } @@ -784,7 +834,7 @@ public sealed class ConnectionService : IDisposable } else { - var model = new ChannelModel( + ChannelModel model = new ChannelModel( channel.Id.ToString(), channel.Name, [], diff --git a/src/Decho/ViewModels/MainWindowViewModel.cs b/src/Decho/ViewModels/MainWindowViewModel.cs index 32a40ae..8227375 100644 --- a/src/Decho/ViewModels/MainWindowViewModel.cs +++ b/src/Decho/ViewModels/MainWindowViewModel.cs @@ -10,6 +10,7 @@ using EchoHub.Client.Services; using EchoHub.Core.Constants; using EchoHub.Core.DTOs; using EchoHub.Core.Models; +using EchoHub.Core.Security; using MsBox.Avalonia; using MsBox.Avalonia.Base; @@ -142,51 +143,55 @@ public sealed class MainWindowViewModel : ViewModelBase private async Task ShowPromptWindowAsync(string title, string message, string buttonText = "OK", bool isPassword = true) { - Window window = new Avalonia.Controls.Window - { - Title = title, - Width = 400, - Height = 200, - WindowStartupLocation = WindowStartupLocation.CenterOwner, - SizeToContent = SizeToContent.Height, - CanResize = false, - }; - - TextBox inputBox = new Avalonia.Controls.TextBox { Watermark = isPassword ? "Password" : "Passphrase", PasswordChar = '*' }; string? result = null; - - Button okBtn = new Avalonia.Controls.Button { Content = buttonText, IsDefault = true }; - Button cancelBtn = new Avalonia.Controls.Button { Content = "Cancel", IsCancel = true }; - - okBtn.Click += (_, _) => + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(async () => { - result = inputBox.Text; - window.Close(); - }; - cancelBtn.Click += (_, _) => window.Close(); - - StackPanel buttons = new Avalonia.Controls.StackPanel - { - Orientation = Avalonia.Layout.Orientation.Horizontal, - HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, - Spacing = 8, - Children = { cancelBtn, okBtn }, - }; - - StackPanel panel = new Avalonia.Controls.StackPanel - { - Margin = new Avalonia.Thickness(12), - Spacing = 8, - Children = + Window window = new Avalonia.Controls.Window { - new Avalonia.Controls.TextBlock { Text = message }, - inputBox, - buttons, - }, - }; + Title = title, + Width = 400, + Height = 200, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + SizeToContent = SizeToContent.Height, + CanResize = false, + }; + + TextBox inputBox = new Avalonia.Controls.TextBox { Watermark = isPassword ? "Password" : "Passphrase", PasswordChar = '*' }; + + Button okBtn = new Avalonia.Controls.Button { Content = buttonText, IsDefault = true }; + Button cancelBtn = new Avalonia.Controls.Button { Content = "Cancel", IsCancel = true }; + + okBtn.Click += (_, _) => + { + result = inputBox.Text; + window.Close(); + }; + cancelBtn.Click += (_, _) => window.Close(); + + StackPanel buttons = new Avalonia.Controls.StackPanel + { + Orientation = Avalonia.Layout.Orientation.Horizontal, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, + Spacing = 8, + Children = { cancelBtn, okBtn }, + }; + + StackPanel panel = new Avalonia.Controls.StackPanel + { + Margin = new Avalonia.Thickness(12), + Spacing = 8, + Children = + { + new Avalonia.Controls.TextBlock { Text = message }, + inputBox, + buttons, + }, + }; + + window.Content = panel; + await window.ShowDialog(_mainWindow!); + }); - window.Content = panel; - await window.ShowDialog(_mainWindow!); return result; } @@ -216,22 +221,52 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - ChannelJoinResult result = await ConnectionService.JoinChannelAsync(serverUrl, channelName, password); - EnsureChannelInList(serverUrl, channelName); + ChannelCryptoDto? crypto = await ConnectionService.GetChannelCryptoAsync(serverUrl, channelName); + bool isEncrypted = crypto is not null && crypto.IsEncrypted; - // Handle E2EE unlock if needed - if (result.IsEncrypted && result.EncryptionSalt is not null && result.WrappedRoomKey is not null) + string? wirePassword = password; + + if (isEncrypted) { - string? passphrase = await ShowPromptWindowAsync("Unlock Channel", "Enter the passphrase to unlock messages:", "Unlock"); - if (string.IsNullOrEmpty(passphrase)) + ServerConnection entry = ConnectionService.Connections[serverUrl]; + entry.Manager.RoomKeys.MarkChannelEncrypted(channelName, true); + + if (!entry.Manager.RoomKeys.HasKey(channelName) && password is null) { - return; + password = await ShowPromptWindowAsync("Unlock Channel", "Enter the passphrase to unlock messages:", "Unlock"); + if (string.IsNullOrEmpty(password)) + { + return; + } } - ChannelJoinResult unlockResult = await ConnectionService.UnlockRoomKeyAsync(serverUrl, channelName, passphrase, result.EncryptionSalt, result.WrappedRoomKey); - if (unlockResult.History.Count > 0) + if (password is not null) { - result = unlockResult; + byte[] salt = Convert.FromBase64String(crypto!.EncryptionSalt!); + wirePassword = RoomCrypto.DeriveKeys(password, salt).AuthKeyHex; + } + } + + ChannelJoinResult result = await ConnectionService.JoinChannelAsync(serverUrl, channelName, wirePassword); + EnsureChannelInList(serverUrl, channelName); + + if (isEncrypted && !ConnectionService.Connections[serverUrl].Manager.RoomKeys.HasKey(channelName)) + { + try + { + ChannelJoinResult unlockResult = await ConnectionService.UnlockRoomKeyAsync( + serverUrl, channelName, password, crypto!.EncryptionSalt!, result.WrappedRoomKey ?? ""); + if (unlockResult.History.Count > 0) + { + result = unlockResult; + } + } + catch (Exception ex) + { + IMsBox box = MessageBoxManager.GetMessageBoxStandard( + "Decrypt Error", $"Decrypt failed: {ex.Message}", ButtonEnum.Ok); + _ = await box.ShowWindowDialogAsync(_mainWindow); + return; } } @@ -878,28 +913,57 @@ public sealed class MainWindowViewModel : ViewModelBase { try { - ChannelJoinResult joinResult = await ConnectionService.JoinChannelAsync(serverUrl, channel.Name, password); + ChannelCryptoDto? crypto = await ConnectionService.GetChannelCryptoAsync(serverUrl, channel.Name); + bool isEncrypted = crypto is not null && crypto.IsEncrypted; - // Handle E2EE unlock if needed - if (joinResult.IsEncrypted && joinResult.EncryptionSalt is not null && joinResult.WrappedRoomKey is not null) + string? wirePassword = password; + + if (isEncrypted) { - string? passphrase = await ShowPromptWindowAsync($"Unlock Channel", "This channel is encrypted. Please enter the passphrase to unlock it:", "Unlock"); + ServerConnection entry = ConnectionService.Connections[serverUrl]; + entry.Manager.RoomKeys.MarkChannelEncrypted(channel.Name, true); - if (string.IsNullOrEmpty(passphrase)) + if (!entry.Manager.RoomKeys.HasKey(channel.Name) && password is null) { - // User cancelled - leave channel locked - Avalonia.Threading.Dispatcher.UIThread.Post(() => + string? passphrase = await ShowPromptWindowAsync("Unlock Channel", "Enter the passphrase to unlock messages:", "Unlock"); + if (string.IsNullOrEmpty(passphrase)) { - channel.IsLocked = true; - Chat.Composer.IsConnected = false; - }); - break; + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + channel.IsLocked = true; + Chat.Composer.IsConnected = false; + }); + break; + } + password = passphrase; } - ChannelJoinResult unlockResult = await ConnectionService.UnlockRoomKeyAsync(serverUrl, channel.Name, passphrase, joinResult.EncryptionSalt, joinResult.WrappedRoomKey); - if (unlockResult.History.Count > 0) + if (password is not null) { - joinResult = unlockResult; // Use decrypted history + byte[] salt = Convert.FromBase64String(crypto!.EncryptionSalt!); + wirePassword = RoomCrypto.DeriveKeys(password, salt).AuthKeyHex; + } + } + + ChannelJoinResult joinResult = await ConnectionService.JoinChannelAsync(serverUrl, channel.Name, wirePassword); + + if (isEncrypted && !ConnectionService.Connections[serverUrl].Manager.RoomKeys.HasKey(channel.Name)) + { + try + { + ChannelJoinResult unlockResult = await ConnectionService.UnlockRoomKeyAsync( + serverUrl, channel.Name, password, crypto!.EncryptionSalt!, joinResult.WrappedRoomKey ?? ""); + if (unlockResult.History.Count > 0) + { + joinResult = unlockResult; + } + } + catch (Exception ex) + { + IMsBox errBox = MessageBoxManager.GetMessageBoxStandard( + "Decrypt Error", $"Decrypt failed: {ex.Message}", ButtonEnum.Ok); + _ = await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync( + () => errBox.ShowWindowDialogAsync(_mainWindow)); } }