feat: implement end-to-end encryption for room keys with secure storage and migration support

This commit is contained in:
HueByte
2026-07-16 18:47:28 +02:00
parent b6c01dab15
commit e439c8ae72
13 changed files with 531 additions and 26 deletions
+108 -16
View File
@@ -35,6 +35,10 @@ public sealed class AppOrchestrator : IDisposable
private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase);
private readonly List<string> _stagedAttachments = [];
// E2E channels whose unlock prompt the user cancelled — don't nag on every reselect.
// Cleared on connect/reconnect; an explicit /join or a send attempt re-offers the prompt.
private readonly HashSet<string> _declinedUnlocks = new(StringComparer.OrdinalIgnoreCase);
private ClientConfig _config;
private readonly UserSession _session = new();
@@ -187,7 +191,8 @@ public sealed class AppOrchestrator : IDisposable
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
&& (uri.Scheme == "http" || uri.Scheme == "https"))
{
if (_conn.RoomKeys.HasKey(channel))
// Also blocks locked E2E channels (no cached key) — a URL send would be plaintext
if (_conn.RoomKeys.HasKey(channel) || _conn.RoomKeys.IsChannelEncrypted(channel))
{
InvokeUI(() => _mainWindow.ShowError(
"Sending by URL isn't available in encrypted channels — download the file and /send it instead."));
@@ -290,15 +295,20 @@ public sealed class AppOrchestrator : IDisposable
/// </summary>
private void SendStagedMessage(string channel, string content)
{
var staged = _stagedAttachments.ToList();
_stagedAttachments.Clear();
InvokeUI(RefreshStagingTray);
var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey);
var size = _config.DefaultAsciiSize;
RunAsync(async () =>
{
// Locked E2E channel: block the send and keep the files staged for after the unlock
if (!await EnsureRoomUnlockedForSendAsync(channel))
return;
var staged = _stagedAttachments.ToList();
_stagedAttachments.Clear();
InvokeUI(RefreshStagingTray);
var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey);
var outgoing = new List<OutgoingAttachment>();
foreach (var path in staged)
outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size));
@@ -417,6 +427,8 @@ public sealed class AppOrchestrator : IDisposable
try
{
crypto = await _conn.Api!.GetChannelCryptoAsync(channelName);
if (crypto is not null)
_conn.RoomKeys.MarkChannelEncrypted(channelName, crypto.IsEncrypted);
}
catch (Exception ex)
{
@@ -438,16 +450,20 @@ public sealed class AppOrchestrator : IDisposable
{
var outcome = await _conn.JoinChannelAsync(channelName, wirePassword);
if (outcome.WrappedRoomKey is not null && !_conn.RoomKeys.HasKey(channelName))
if (outcome.WrappedRoomKey is not null)
{
if (kek is not null && RoomCrypto.TryUnwrapRoomKey(outcome.WrappedRoomKey, kek, out var roomKey))
// A typed passphrase always wins over the cache: unwrap the fresh envelope
// and overwrite any stale key (e.g. the channel was deleted and recreated
// under the same name — the old key would encrypt for nobody).
if (kek is not null && _conn.RoomKeys.TryStoreFromEnvelope(channelName, outcome.WrappedRoomKey, kek))
{
_conn.RoomKeys.StoreKey(channelName, roomKey);
lock (_declinedUnlocks) _declinedUnlocks.Remove(channelName);
// Re-fetch so history decrypts with the now-available room key
return await _conn.GetHistoryAsync(channelName);
}
return await UnlockRoomKeyAsync(channelName, outcome);
if (!_conn.RoomKeys.HasKey(channelName))
return await UnlockRoomKeyAsync(channelName, outcome);
}
return outcome.History;
@@ -484,12 +500,17 @@ public sealed class AppOrchestrator : IDisposable
var passphrase = await prompt.Task;
if (passphrase is null)
return outcome.History; // stays locked; placeholders render instead of content
{
// Stays locked; placeholders render instead of content. Remember the decline
// so reselecting the channel doesn't nag every time.
lock (_declinedUnlocks) _declinedUnlocks.Add(channelName);
return outcome.History;
}
var derived = RoomCrypto.DeriveKeys(passphrase, salt);
if (RoomCrypto.TryUnwrapRoomKey(outcome.WrappedRoomKey, derived.KeyEncryptionKey, out var roomKey))
if (_conn.RoomKeys.TryStoreFromEnvelope(channelName, outcome.WrappedRoomKey, derived.KeyEncryptionKey))
{
_conn.RoomKeys.StoreKey(channelName, roomKey);
lock (_declinedUnlocks) _declinedUnlocks.Remove(channelName);
return await _conn.GetHistoryAsync(channelName);
}
@@ -497,6 +518,66 @@ public sealed class AppOrchestrator : IDisposable
}
}
/// <summary>
/// True when a channel is end-to-end encrypted, its room key isn't cached, and the
/// user hasn't already declined the unlock prompt this session.
/// </summary>
private bool NeedsUnlockPrompt(string channelName)
{
if (!_conn.RoomKeys.IsChannelEncrypted(channelName) || _conn.RoomKeys.HasKey(channelName))
return false;
lock (_declinedUnlocks) return !_declinedUnlocks.Contains(channelName);
}
/// <summary>
/// Unlock flow for a channel that is already hub-joined (auto-join/reconnect discard
/// the key envelope): rejoin — members pass the gate without a password and the join
/// result carries the envelope — then run the passphrase prompt. On success the
/// decrypted history replaces the locked placeholders. Returns true when unlocked.
/// </summary>
private async Task<bool> UnlockTrackedChannelAsync(string channelName)
{
try
{
var outcome = await _conn.JoinChannelAsync(channelName, null);
if (outcome.WrappedRoomKey is null)
return false; // not an E2E channel after all
var history = await UnlockRoomKeyAsync(channelName, outcome);
if (!_conn.RoomKeys.HasKey(channelName))
return false; // cancelled or never unwrapped
if (history is not null)
InvokeUI(() => _messageManager.LoadHistory(channelName, history));
return true;
}
catch (Exception ex)
{
Log.Warning(ex, "Unlock flow failed for #{Channel}", channelName);
return false;
}
}
/// <summary>
/// Send guard for end-to-end encrypted channels: without the cached room key nothing
/// may leave the client (it would be plaintext in a room others read as encrypted).
/// Offers the unlock prompt right away — even after an earlier decline, since the user
/// is actively trying to talk here. Returns true when sending is safe.
/// </summary>
private async Task<bool> EnsureRoomUnlockedForSendAsync(string channelName)
{
if (!_conn.RoomKeys.IsChannelEncrypted(channelName) || _conn.RoomKeys.HasKey(channelName))
return true;
if (await UnlockTrackedChannelAsync(channelName))
return true;
InvokeUI(() => _mainWindow.ShowError(
$"#{channelName} is end-to-end encrypted and locked — nothing was sent. Enter its passphrase to unlock it first."));
return false;
}
/// <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
@@ -922,6 +1003,7 @@ public sealed class AppOrchestrator : IDisposable
_conn.Reconnected += () =>
{
lock (_channelUsersLock) _channelUsers.Clear();
lock (_declinedUnlocks) _declinedUnlocks.Clear();
RunAsync(
async () => await _conn.RejoinChannelsAsync(),
"Failed to rejoin channels after reconnect");
@@ -970,6 +1052,7 @@ public sealed class AppOrchestrator : IDisposable
}
_session.Username = result.Login.Username;
lock (_declinedUnlocks) _declinedUnlocks.Clear();
// Persisted last-read markers for this server — used to seed unread counts,
// mention highlights, and "new messages" markers from the fetched histories.
@@ -1073,9 +1156,12 @@ public sealed class AppOrchestrator : IDisposable
return;
}
RunAsync(
async () => await _conn.SendMessageAsync(channelName, content),
"Send failed");
RunAsync(async () =>
{
if (!await EnsureRoomUnlockedForSendAsync(channelName))
return;
await _conn.SendMessageAsync(channelName, content);
}, "Send failed");
}
private void HandleDeleteMessageRequested(Guid messageId)
@@ -1112,6 +1198,12 @@ public sealed class AppOrchestrator : IDisposable
UpdateServerConfig(server =>
server.LeftChannels.RemoveAll(c => c.Equals(channelName, StringComparison.OrdinalIgnoreCase)));
}
else if (NeedsUnlockPrompt(channelName))
{
// Auto-join/reconnect already hub-joined this E2E channel but discarded the
// key envelope — selecting it is the user's cue to unlock it.
await UnlockTrackedChannelAsync(channelName);
}
try
{