mirror of
https://github.com/Stone-Red-Code/Decho.git
synced 2026-09-04 08:56:14 +02:00
Add passphrase popup for encrypted channels
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user