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
{
+2 -1
View File
@@ -38,7 +38,8 @@ public class SavedServer
/// <summary>
/// Cached room content keys for end-to-end encrypted channels on this server,
/// keyed by channel name (base64). Like RefreshToken, these live only on the
/// keyed by channel name and encrypted at rest (see RoomKeyProtector; legacy
/// entries were plain base64). Like RefreshToken, these live only on the
/// user's machine — the server never sees them.
/// </summary>
public Dictionary<string, string> ChannelKeys { get; set; } = [];
@@ -9,6 +9,9 @@ public static class ConfigManager
private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json");
/// <summary>Directory holding the client config and local key material.</summary>
public static string ConfigDirectory => ConfigDir;
// Load-mutate-save cycles run from both the UI thread and background tasks
// (token refresh, room keys, last-read checkpoints) — serialize file access.
private static readonly Lock FileLock = new();
+1
View File
@@ -12,6 +12,7 @@
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.3" />
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5043" />
</ItemGroup>
@@ -112,6 +112,11 @@ internal sealed class ConnectionManager : IAsyncDisposable
var channels = await _apiClient.GetChannelsAsync();
// Known E2E channels — senders consult this so a client without the room
// key never emits plaintext into an encrypted room
foreach (var channel in channels)
_roomKeys.MarkChannelEncrypted(channel.Name, channel.IsEncrypted);
// Join default channel + fetch history
onStatus("Joining channels...");
_joinedChannels.Clear();
@@ -296,7 +301,11 @@ internal sealed class ConnectionManager : IAsyncDisposable
connection.OnForceDisconnect += reason => ForceDisconnected?.Invoke(reason);
connection.OnMessageDeleted += (ch, id) => MessageDeleted?.Invoke(ch, id);
connection.OnChannelNuked += ch => ChannelNuked?.Invoke(ch);
connection.OnChannelUpdated += ch => ChannelUpdated?.Invoke(ch);
connection.OnChannelUpdated += ch =>
{
_roomKeys.MarkChannelEncrypted(ch.Name, ch.IsEncrypted);
ChannelUpdated?.Invoke(ch);
};
connection.OnError += msg => Error?.Invoke(msg);
connection.OnConnectionStateChanged += status => ConnectionStatusChanged?.Invoke(status);
connection.OnReconnected += () => Reconnected?.Invoke();
@@ -26,6 +26,21 @@ public sealed class ChannelPasswordRequiredException : Exception
}
}
/// <summary>
/// Thrown when sending into an end-to-end encrypted channel whose room key isn't cached:
/// without the key the message would leave the client as plaintext, which must never happen.
/// </summary>
public sealed class RoomLockedException : Exception
{
public string ChannelName { get; }
public RoomLockedException(string channelName)
: base($"#{channelName} is end-to-end encrypted and locked — enter its passphrase to unlock it before sending.")
{
ChannelName = channelName;
}
}
public sealed class EchoHubConnection : IAsyncDisposable
{
public const string LockedMessagePlaceholder =
@@ -167,6 +182,8 @@ public sealed class EchoHubConnection : IAsyncDisposable
throw new ChannelPasswordRequiredException(channelName, result.Error ?? "Channel is password protected.");
throw new InvalidOperationException(result.Error ?? "Failed to join channel.");
}
if (result.WrappedRoomKey is not null)
_roomKeys.MarkChannelEncrypted(channelName, true);
return new JoinOutcome(DecryptMessages(result.History), result.EncryptionSalt, result.WrappedRoomKey);
}
@@ -180,6 +197,8 @@ public sealed class EchoHubConnection : IAsyncDisposable
// Room layer first (end-to-end, server can't read), then transport encryption
if (_roomKeys.TryGetKey(channelName, out var roomKey))
content = RoomCrypto.EncryptText(content, roomKey);
else if (_roomKeys.IsChannelEncrypted(channelName))
throw new RoomLockedException(channelName); // never fall through to plaintext
var encrypted = _encryption.Encrypt(content);
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
@@ -0,0 +1,120 @@
using System.Security.Cryptography;
using EchoHub.Core.Security;
using Serilog;
namespace EchoHub.Client.Services;
/// <summary>
/// Encrypts cached room content keys at rest so the client config never holds them as
/// plain base64. Windows uses DPAPI (current-user scope, format prefix "dp1:"). On other
/// platforms the keys are AES-GCM encrypted with a per-user master key file stored next
/// to the config with 0600 permissions (prefix "k1:") — without an OS keychain that is
/// file-permission-level protection, not zero-knowledge: anyone who can read both the
/// config and the key file can recover the room keys. Values with no recognized prefix
/// are legacy plain-base64 keys from older clients; they load once and are re-encrypted.
/// The room passphrase itself is never stored in any form.
/// </summary>
public sealed class RoomKeyProtector
{
public const string DpapiPrefix = "dp1:";
public const string KeyFilePrefix = "k1:";
private const string KeyFileName = "roomkeys.key";
private const int MasterKeySizeBytes = 32;
private const int RoomKeySizeBytes = 32;
private readonly string _keyFilePath;
private readonly bool _useDpapi;
private readonly Lock _lock = new();
private byte[]? _masterKey;
/// <param name="keyDirectory">Directory holding the master key file (the client config dir).</param>
/// <param name="useDpapi">Overrides the platform default (DPAPI on Windows) — for tests.</param>
public RoomKeyProtector(string keyDirectory, bool? useDpapi = null)
{
_keyFilePath = Path.Combine(keyDirectory, KeyFileName);
_useDpapi = useDpapi ?? OperatingSystem.IsWindows();
}
/// <summary>Encrypts a room key for storage in the config file.</summary>
public string Protect(byte[] roomKey)
{
if (_useDpapi && OperatingSystem.IsWindows())
return DpapiPrefix + Convert.ToBase64String(
ProtectedData.Protect(roomKey, null, DataProtectionScope.CurrentUser));
return KeyFilePrefix + Convert.ToBase64String(RoomCrypto.EncryptBytes(roomKey, GetMasterKey()));
}
/// <summary>
/// Decrypts a stored value back into a room key. <paramref name="wasLegacy"/> is true when
/// the value was an unencrypted legacy entry that should be re-persisted via
/// <see cref="Protect"/>. Returns false for unreadable values (wrong user/machine, missing
/// or regenerated key file, malformed data) — the caller drops the entry and the user can
/// recover it by re-entering the passphrase.
/// </summary>
public bool TryUnprotect(string stored, out byte[] roomKey, out bool wasLegacy)
{
roomKey = [];
wasLegacy = false;
try
{
if (stored.StartsWith(DpapiPrefix, StringComparison.Ordinal))
{
if (!OperatingSystem.IsWindows())
return false; // config copied from a Windows machine
roomKey = ProtectedData.Unprotect(
Convert.FromBase64String(stored[DpapiPrefix.Length..]), null, DataProtectionScope.CurrentUser);
return roomKey.Length > 0;
}
if (stored.StartsWith(KeyFilePrefix, StringComparison.Ordinal))
{
if (!File.Exists(_keyFilePath))
return false;
roomKey = RoomCrypto.DecryptBytes(
Convert.FromBase64String(stored[KeyFilePrefix.Length..]), GetMasterKey());
return roomKey.Length > 0;
}
// No recognized prefix — legacy plain-base64 room key from a pre-encryption client
roomKey = Convert.FromBase64String(stored);
wasLegacy = true;
return roomKey.Length == RoomKeySizeBytes;
}
catch (Exception ex) when (ex is FormatException or CryptographicException
or IOException or UnauthorizedAccessException)
{
return false;
}
}
private byte[] GetMasterKey()
{
lock (_lock)
{
if (_masterKey is not null)
return _masterKey;
if (File.Exists(_keyFilePath))
{
var existing = File.ReadAllBytes(_keyFilePath);
if (existing.Length == MasterKeySizeBytes)
return _masterKey = existing;
Log.Warning("Room-key master key file has unexpected size — regenerating (previously cached keys become unreadable)");
}
var key = RandomNumberGenerator.GetBytes(MasterKeySizeBytes);
Directory.CreateDirectory(Path.GetDirectoryName(_keyFilePath)!);
File.WriteAllBytes(_keyFilePath, key);
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(_keyFilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
return _masterKey = key;
}
}
}
+76 -7
View File
@@ -1,4 +1,5 @@
using EchoHub.Client.Config;
using EchoHub.Core.Security;
using Serilog;
namespace EchoHub.Client.Services;
@@ -6,14 +7,28 @@ namespace EchoHub.Client.Services;
/// <summary>
/// Holds room content keys for end-to-end encrypted channels: in-memory for the
/// active session, persisted per-server in the client config (like saved sessions)
/// so users don't retype the passphrase every launch. Keys never leave this machine.
/// so users don't retype the passphrase every launch. Keys never leave this machine
/// and are encrypted at rest by <see cref="RoomKeyProtector"/>. Also tracks which
/// channels are known to be end-to-end encrypted, so senders can refuse to emit
/// plaintext into a room whose key isn't cached yet.
/// </summary>
public sealed class RoomKeyStore
{
private readonly Dictionary<string, byte[]> _keys = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _encryptedChannels = new(StringComparer.OrdinalIgnoreCase);
private readonly RoomKeyProtector _protector;
private readonly Lock _lock = new();
private string? _serverUrl;
public RoomKeyStore() : this(new RoomKeyProtector(ConfigManager.ConfigDirectory))
{
}
public RoomKeyStore(RoomKeyProtector protector)
{
_protector = protector;
}
/// <summary>Binds the store to a server and loads that server's cached keys from config.</summary>
public void LoadForServer(string serverUrl)
{
@@ -21,21 +36,34 @@ public sealed class RoomKeyStore
{
_serverUrl = serverUrl;
_keys.Clear();
_encryptedChannels.Clear();
var server = FindServer(ConfigManager.Load(), serverUrl);
if (server is null) return;
foreach (var (channel, base64) in server.ChannelKeys)
var legacyFound = false;
foreach (var (channel, stored) in server.ChannelKeys)
{
try
if (_protector.TryUnprotect(stored, out var key, out var wasLegacy))
{
_keys[channel] = Convert.FromBase64String(base64);
_keys[channel] = key;
legacyFound |= wasLegacy;
}
catch (FormatException)
else
{
Log.Warning("Ignoring malformed cached room key for #{Channel}", channel);
Log.Warning("Ignoring unreadable cached room key for #{Channel}", channel);
}
}
// One-way upgrade: legacy plain-base64 entries get re-persisted encrypted
// (unreadable entries drop out — the unlock prompt recovers those rooms).
if (legacyFound)
Persist(s =>
{
s.ChannelKeys.Clear();
foreach (var (channel, key) in _keys)
s.ChannelKeys[channel] = _protector.Protect(key);
});
}
}
@@ -62,10 +90,26 @@ public sealed class RoomKeyStore
lock (_lock)
{
_keys[channelName] = key;
Persist(server => server.ChannelKeys[channelName] = Convert.ToBase64String(key));
_encryptedChannels.Add(channelName);
Persist(server => server.ChannelKeys[channelName] = _protector.Protect(key));
}
}
/// <summary>
/// Unwraps a fresh key envelope and caches the key, overwriting any stale cached key
/// (e.g. the channel was deleted and recreated under the same name, so the old key
/// would encrypt messages nobody else can read). Returns false when the KEK doesn't
/// open the envelope — the cache is left untouched.
/// </summary>
public bool TryStoreFromEnvelope(string channelName, string wrappedRoomKey, byte[] kek)
{
if (!RoomCrypto.TryUnwrapRoomKey(wrappedRoomKey, kek, out var roomKey))
return false;
StoreKey(channelName, roomKey);
return true;
}
public void RemoveKey(string channelName)
{
lock (_lock)
@@ -75,11 +119,36 @@ public sealed class RoomKeyStore
}
}
/// <summary>
/// Records whether a channel is end-to-end encrypted (from channel listings, crypto
/// metadata, or join outcomes). Senders consult this to block plaintext into rooms
/// whose key isn't cached.
/// </summary>
public void MarkChannelEncrypted(string channelName, bool isEncrypted)
{
lock (_lock)
{
if (isEncrypted)
_encryptedChannels.Add(channelName);
else
_encryptedChannels.Remove(channelName);
}
}
public bool IsChannelEncrypted(string channelName)
{
lock (_lock)
{
return _encryptedChannels.Contains(channelName);
}
}
public void Clear()
{
lock (_lock)
{
_keys.Clear();
_encryptedChannels.Clear();
_serverUrl = null;
}
}