From 9f1ffa7802b51a9d534cce3e9da9124c31257426 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:44:35 +0200 Subject: [PATCH] Add passphrase popup for encrypted channels --- src/Decho/Services/ConnectionService.cs | 62 ++++++++++++++++- src/Decho/ViewModels/MainWindowViewModel.cs | 77 ++++++++++++++++----- 2 files changed, 117 insertions(+), 22 deletions(-) diff --git a/src/Decho/Services/ConnectionService.cs b/src/Decho/Services/ConnectionService.cs index af7d821..7365bee 100644 --- a/src/Decho/Services/ConnectionService.cs +++ b/src/Decho/Services/ConnectionService.cs @@ -13,6 +13,25 @@ using System.Collections.ObjectModel; namespace Decho.Services; +/// +/// Result of joining a channel, including E2EE encryption metadata if the channel is encrypted. +/// +public sealed class ChannelJoinResult +{ + public List History { get; } + public bool IsEncrypted { get; } + public string? EncryptionSalt { get; } + public string? WrappedRoomKey { get; } + + public ChannelJoinResult(List history, bool isEncrypted = false, string? encryptionSalt = null, string? wrappedRoomKey = null) + { + History = history; + IsEncrypted = isEncrypted; + EncryptionSalt = encryptionSalt; + WrappedRoomKey = wrappedRoomKey; + } +} + public sealed class ConnectionService : IDisposable { public event Action? ServerAdded; @@ -101,7 +120,7 @@ public sealed class ConnectionService : IDisposable return channel; } - public async Task> JoinChannelAsync(string serverUrl, string channelName, string? password = null) + public async Task JoinChannelAsync(string serverUrl, string channelName, string? password = null) { if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) { @@ -112,12 +131,49 @@ public sealed class ConnectionService : IDisposable { JoinOutcome outcome = await entry.Manager.JoinChannelAsync(channelName, password); RemoveFromLeftChannels(serverUrl, channelName); - return outcome.History.Select(m => MessageModelFromDto(m, entry)).ToList(); + + List history = outcome.History.Select(m => MessageModelFromDto(m, entry)).ToList(); + + bool isEncrypted = !string.IsNullOrEmpty(outcome.EncryptionSalt) && !string.IsNullOrEmpty(outcome.WrappedRoomKey); + bool hasKey = entry.Manager.RoomKeys.HasKey(channelName); + + return new ChannelJoinResult(history, isEncrypted && !hasKey, outcome.EncryptionSalt, outcome.WrappedRoomKey); } RemoveFromLeftChannels(serverUrl, channelName); List existing = await entry.Manager.GetHistoryAsync(channelName); - return existing.Select(m => MessageModelFromDto(m, entry)).ToList(); + 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); + } + + public async Task UnlockRoomKeyAsync(string serverUrl, string channelName, string passphrase, string encryptionSalt, string wrappedRoomKey) + { + if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) + { + throw new InvalidOperationException("Not connected to server"); + } + + if (!entry.Manager.RoomKeys.IsChannelEncrypted(channelName)) + { + return new ChannelJoinResult([], false, null, null); + } + + byte[] salt = Convert.FromBase64String(encryptionSalt); + var derived = RoomCrypto.DeriveKeys(passphrase, salt); + + if (!entry.Manager.RoomKeys.TryStoreFromEnvelope(channelName, wrappedRoomKey, derived.KeyEncryptionKey)) + { + throw new InvalidOperationException("Wrong passphrase"); + } + + // Fetch fresh history now that the key is available + List history = await entry.Manager.GetHistoryAsync(channelName); + return new ChannelJoinResult(history.Select(m => MessageModelFromDto(m, entry)).ToList(), false, null, null); } public async Task LeaveChannelAsync(string serverUrl, string channelName) diff --git a/src/Decho/ViewModels/MainWindowViewModel.cs b/src/Decho/ViewModels/MainWindowViewModel.cs index 556f015..dd08431 100644 --- a/src/Decho/ViewModels/MainWindowViewModel.cs +++ b/src/Decho/ViewModels/MainWindowViewModel.cs @@ -140,27 +140,27 @@ public sealed class MainWindowViewModel : ViewModelBase return await dialog.ShowDialog(_mainWindow); } - private async Task ShowPasswordPromptWindowAsync() + private async Task ShowPromptWindowAsync(string title, string message, string buttonText = "OK", bool isPassword = true) { Window window = new Avalonia.Controls.Window { - Title = "Channel Password", - Width = 320, - Height = 180, + Title = title, + Width = 400, + Height = 200, WindowStartupLocation = WindowStartupLocation.CenterOwner, SizeToContent = SizeToContent.Height, CanResize = false, }; - TextBox passwordBox = new Avalonia.Controls.TextBox { Watermark = "Password", PasswordChar = '*' }; + TextBox inputBox = new Avalonia.Controls.TextBox { Watermark = isPassword ? "Password" : "Passphrase", PasswordChar = '*' }; string? result = null; - Button joinBtn = new Avalonia.Controls.Button { Content = "Join", IsDefault = true }; + Button okBtn = new Avalonia.Controls.Button { Content = buttonText, IsDefault = true }; Button cancelBtn = new Avalonia.Controls.Button { Content = "Cancel", IsCancel = true }; - joinBtn.Click += (_, _) => + okBtn.Click += (_, _) => { - result = passwordBox.Text; + result = inputBox.Text; window.Close(); }; cancelBtn.Click += (_, _) => window.Close(); @@ -170,7 +170,7 @@ public sealed class MainWindowViewModel : ViewModelBase Orientation = Avalonia.Layout.Orientation.Horizontal, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, Spacing = 8, - Children = { cancelBtn, joinBtn }, + Children = { cancelBtn, okBtn }, }; StackPanel panel = new Avalonia.Controls.StackPanel @@ -179,9 +179,8 @@ public sealed class MainWindowViewModel : ViewModelBase Spacing = 8, Children = { - new Avalonia.Controls.TextBlock { Text = "This channel is password protected." }, - new Avalonia.Controls.TextBlock { Text = "Enter the channel password:" }, - passwordBox, + new Avalonia.Controls.TextBlock { Text = message }, + inputBox, buttons, }, }; @@ -217,8 +216,25 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - List channel = await ConnectionService.JoinChannelAsync(serverUrl, channelName, password); + ChannelJoinResult result = await ConnectionService.JoinChannelAsync(serverUrl, channelName, password); EnsureChannelInList(serverUrl, channelName); + + // Handle E2EE unlock if needed + if (result.IsEncrypted && result.EncryptionSalt is not null && result.WrappedRoomKey is not null) + { + string? passphrase = await ShowPromptWindowAsync("Unlock Channel", "Enter the passphrase to unlock messages:", "Unlock"); + if (string.IsNullOrEmpty(passphrase)) + { + return; + } + + ChannelJoinResult unlockResult = await ConnectionService.UnlockRoomKeyAsync(serverUrl, channelName, passphrase, result.EncryptionSalt, result.WrappedRoomKey); + if (unlockResult.History.Count > 0) + { + result = unlockResult; + } + } + ChannelModel? channelModel = FindChannel(serverUrl, channelName); if (channelModel is not null) { @@ -226,7 +242,7 @@ public sealed class MainWindowViewModel : ViewModelBase .FirstOrDefault(c => c.Name == channelName); if (channelVm is not null) { - foreach (MessageModel msg in channel) + foreach (MessageModel msg in result.History) { channelVm.AddMessage(msg); } @@ -722,7 +738,7 @@ public sealed class MainWindowViewModel : ViewModelBase return; } - List history = await ConnectionService.JoinChannelAsync(server.ServerUrl, channel.Name); + ChannelJoinResult joinResult = await ConnectionService.JoinChannelAsync(server.ServerUrl, channel.Name); ChannelModel channelModel = new ChannelModel( channel.Id.ToString(), channel.Name, [], channel.Topic, channel.IsPublic, channel.IsProtected); @@ -737,7 +753,7 @@ public sealed class MainWindowViewModel : ViewModelBase serverVm.Channels.Add(channelVm); serverVm.SelectedChannel = channelVm; - foreach (MessageModel msg in history) + foreach (MessageModel msg in joinResult.History) { channelVm.AddMessage(msg); } @@ -850,7 +866,30 @@ public sealed class MainWindowViewModel : ViewModelBase { try { - List history = await ConnectionService.JoinChannelAsync(serverUrl, channel.Name, password); + ChannelJoinResult joinResult = await ConnectionService.JoinChannelAsync(serverUrl, channel.Name, password); + + // Handle E2EE unlock if needed + if (joinResult.IsEncrypted && joinResult.EncryptionSalt is not null && joinResult.WrappedRoomKey is not null) + { + string? passphrase = await ShowPromptWindowAsync($"Unlock Channel", "This channel is encrypted. Please enter the passphrase to unlock it:", "Unlock"); + + if (string.IsNullOrEmpty(passphrase)) + { + // User cancelled - leave channel locked + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + channel.IsLocked = true; + Chat.Composer.IsConnected = false; + }); + break; + } + + ChannelJoinResult unlockResult = await ConnectionService.UnlockRoomKeyAsync(serverUrl, channel.Name, passphrase, joinResult.EncryptionSalt, joinResult.WrappedRoomKey); + if (unlockResult.History.Count > 0) + { + joinResult = unlockResult; // Use decrypted history + } + } Avalonia.Threading.Dispatcher.UIThread.Post(() => { @@ -859,7 +898,7 @@ public sealed class MainWindowViewModel : ViewModelBase if (channel.Messages.Count == 0) { - foreach (MessageModel msg in history) + foreach (MessageModel msg in joinResult.History) { channel.AddMessage(msg); } @@ -878,7 +917,7 @@ public sealed class MainWindowViewModel : ViewModelBase return null; } - return await ShowPasswordPromptWindowAsync(); + return await ShowPromptWindowAsync("Channel Password", "This channel is password protected.\nEnter the channel password:", "Join"); }); if (string.IsNullOrEmpty(pwd))