Add passphrase popup for encrypted channels

This commit is contained in:
Stone_Red
2026-07-17 02:44:58 +02:00
parent 59807809ec
commit 9f1ffa7802
2 changed files with 117 additions and 22 deletions
+59 -3
View File
@@ -13,6 +13,25 @@ using System.Collections.ObjectModel;
namespace Decho.Services; namespace Decho.Services;
/// <summary>
/// Result of joining a channel, including E2EE encryption metadata if the channel is encrypted.
/// </summary>
public sealed class ChannelJoinResult
{
public List<MessageModel> History { get; }
public bool IsEncrypted { get; }
public string? EncryptionSalt { get; }
public string? WrappedRoomKey { get; }
public ChannelJoinResult(List<MessageModel> history, bool isEncrypted = false, string? encryptionSalt = null, string? wrappedRoomKey = null)
{
History = history;
IsEncrypted = isEncrypted;
EncryptionSalt = encryptionSalt;
WrappedRoomKey = wrappedRoomKey;
}
}
public sealed class ConnectionService : IDisposable public sealed class ConnectionService : IDisposable
{ {
public event Action<ServerModel>? ServerAdded; public event Action<ServerModel>? ServerAdded;
@@ -101,7 +120,7 @@ public sealed class ConnectionService : IDisposable
return channel; return channel;
} }
public async Task<List<MessageModel>> JoinChannelAsync(string serverUrl, string channelName, string? password = null) public async Task<ChannelJoinResult> JoinChannelAsync(string serverUrl, string channelName, string? password = null)
{ {
if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry)) if (!_connections.TryGetValue(serverUrl, out ServerConnection? entry))
{ {
@@ -112,12 +131,49 @@ public sealed class ConnectionService : IDisposable
{ {
JoinOutcome outcome = await entry.Manager.JoinChannelAsync(channelName, password); JoinOutcome outcome = await entry.Manager.JoinChannelAsync(channelName, password);
RemoveFromLeftChannels(serverUrl, channelName); RemoveFromLeftChannels(serverUrl, channelName);
return outcome.History.Select(m => MessageModelFromDto(m, entry)).ToList();
List<MessageModel> 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); RemoveFromLeftChannels(serverUrl, channelName);
List<MessageDto> existing = await entry.Manager.GetHistoryAsync(channelName); List<MessageDto> existing = await entry.Manager.GetHistoryAsync(channelName);
return existing.Select(m => MessageModelFromDto(m, entry)).ToList(); List<MessageModel> 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<ChannelJoinResult> 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<MessageDto> 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) public async Task LeaveChannelAsync(string serverUrl, string channelName)
+58 -19
View File
@@ -140,27 +140,27 @@ public sealed class MainWindowViewModel : ViewModelBase
return await dialog.ShowDialog<ConnectDialogResult?>(_mainWindow); return await dialog.ShowDialog<ConnectDialogResult?>(_mainWindow);
} }
private async Task<string?> ShowPasswordPromptWindowAsync() private async Task<string?> ShowPromptWindowAsync(string title, string message, string buttonText = "OK", bool isPassword = true)
{ {
Window window = new Avalonia.Controls.Window Window window = new Avalonia.Controls.Window
{ {
Title = "Channel Password", Title = title,
Width = 320, Width = 400,
Height = 180, Height = 200,
WindowStartupLocation = WindowStartupLocation.CenterOwner, WindowStartupLocation = WindowStartupLocation.CenterOwner,
SizeToContent = SizeToContent.Height, SizeToContent = SizeToContent.Height,
CanResize = false, 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; 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 }; Button cancelBtn = new Avalonia.Controls.Button { Content = "Cancel", IsCancel = true };
joinBtn.Click += (_, _) => okBtn.Click += (_, _) =>
{ {
result = passwordBox.Text; result = inputBox.Text;
window.Close(); window.Close();
}; };
cancelBtn.Click += (_, _) => window.Close(); cancelBtn.Click += (_, _) => window.Close();
@@ -170,7 +170,7 @@ public sealed class MainWindowViewModel : ViewModelBase
Orientation = Avalonia.Layout.Orientation.Horizontal, Orientation = Avalonia.Layout.Orientation.Horizontal,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
Spacing = 8, Spacing = 8,
Children = { cancelBtn, joinBtn }, Children = { cancelBtn, okBtn },
}; };
StackPanel panel = new Avalonia.Controls.StackPanel StackPanel panel = new Avalonia.Controls.StackPanel
@@ -179,9 +179,8 @@ public sealed class MainWindowViewModel : ViewModelBase
Spacing = 8, Spacing = 8,
Children = Children =
{ {
new Avalonia.Controls.TextBlock { Text = "This channel is password protected." }, new Avalonia.Controls.TextBlock { Text = message },
new Avalonia.Controls.TextBlock { Text = "Enter the channel password:" }, inputBox,
passwordBox,
buttons, buttons,
}, },
}; };
@@ -217,8 +216,25 @@ public sealed class MainWindowViewModel : ViewModelBase
return; return;
} }
List<MessageModel> channel = await ConnectionService.JoinChannelAsync(serverUrl, channelName, password); ChannelJoinResult result = await ConnectionService.JoinChannelAsync(serverUrl, channelName, password);
EnsureChannelInList(serverUrl, channelName); 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); ChannelModel? channelModel = FindChannel(serverUrl, channelName);
if (channelModel is not null) if (channelModel is not null)
{ {
@@ -226,7 +242,7 @@ public sealed class MainWindowViewModel : ViewModelBase
.FirstOrDefault(c => c.Name == channelName); .FirstOrDefault(c => c.Name == channelName);
if (channelVm is not null) if (channelVm is not null)
{ {
foreach (MessageModel msg in channel) foreach (MessageModel msg in result.History)
{ {
channelVm.AddMessage(msg); channelVm.AddMessage(msg);
} }
@@ -722,7 +738,7 @@ public sealed class MainWindowViewModel : ViewModelBase
return; return;
} }
List<MessageModel> history = await ConnectionService.JoinChannelAsync(server.ServerUrl, channel.Name); ChannelJoinResult joinResult = await ConnectionService.JoinChannelAsync(server.ServerUrl, channel.Name);
ChannelModel channelModel = new ChannelModel( ChannelModel channelModel = new ChannelModel(
channel.Id.ToString(), channel.Name, [], channel.Topic, channel.IsPublic, channel.IsProtected); 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.Channels.Add(channelVm);
serverVm.SelectedChannel = channelVm; serverVm.SelectedChannel = channelVm;
foreach (MessageModel msg in history) foreach (MessageModel msg in joinResult.History)
{ {
channelVm.AddMessage(msg); channelVm.AddMessage(msg);
} }
@@ -850,7 +866,30 @@ public sealed class MainWindowViewModel : ViewModelBase
{ {
try try
{ {
List<MessageModel> 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(() => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{ {
@@ -859,7 +898,7 @@ public sealed class MainWindowViewModel : ViewModelBase
if (channel.Messages.Count == 0) if (channel.Messages.Count == 0)
{ {
foreach (MessageModel msg in history) foreach (MessageModel msg in joinResult.History)
{ {
channel.AddMessage(msg); channel.AddMessage(msg);
} }
@@ -878,7 +917,7 @@ public sealed class MainWindowViewModel : ViewModelBase
return null; return null;
} }
return await ShowPasswordPromptWindowAsync(); return await ShowPromptWindowAsync("Channel Password", "This channel is password protected.\nEnter the channel password:", "Join");
}); });
if (string.IsNullOrEmpty(pwd)) if (string.IsNullOrEmpty(pwd))