mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: implement end-to-end encryption for room keys with secure storage and migration support
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>0.2.13</Version>
|
||||
<Version>0.2.14</Version>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Security.Cryptography;
|
||||
using EchoHub.Client.Services;
|
||||
using EchoHub.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace EchoHub.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// At-rest encryption of the cached room keys (RoomKeyProtector) and the store-level
|
||||
/// decisions built on it (RoomKeyStore): fresh envelopes overwrite stale cached keys,
|
||||
/// legacy plain-base64 entries are recognized for the one-way migration.
|
||||
/// </summary>
|
||||
public class RoomKeyProtectorTests : IDisposable
|
||||
{
|
||||
private readonly string _dir = Directory.CreateTempSubdirectory("echohub-keyprotector-").FullName;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFile_Protect_RoundTrips()
|
||||
{
|
||||
var protector = new RoomKeyProtector(_dir, useDpapi: false);
|
||||
var key = RoomCrypto.GenerateRoomKey();
|
||||
|
||||
var stored = protector.Protect(key);
|
||||
|
||||
Assert.StartsWith(RoomKeyProtector.KeyFilePrefix, stored);
|
||||
Assert.True(protector.TryUnprotect(stored, out var recovered, out var wasLegacy));
|
||||
Assert.Equal(key, recovered);
|
||||
Assert.False(wasLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dpapi_Protect_RoundTrips()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return; // DPAPI is Windows-only
|
||||
|
||||
var protector = new RoomKeyProtector(_dir, useDpapi: true);
|
||||
var key = RoomCrypto.GenerateRoomKey();
|
||||
|
||||
var stored = protector.Protect(key);
|
||||
|
||||
Assert.StartsWith(RoomKeyProtector.DpapiPrefix, stored);
|
||||
Assert.True(protector.TryUnprotect(stored, out var recovered, out var wasLegacy));
|
||||
Assert.Equal(key, recovered);
|
||||
Assert.False(wasLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Protect_DoesNotStoreThePlainKey()
|
||||
{
|
||||
var protector = new RoomKeyProtector(_dir, useDpapi: false);
|
||||
var key = RoomCrypto.GenerateRoomKey();
|
||||
|
||||
var stored = protector.Protect(key);
|
||||
|
||||
Assert.DoesNotContain(Convert.ToBase64String(key), stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_PlainBase64_IsAccepted_AndFlaggedForMigration()
|
||||
{
|
||||
var protector = new RoomKeyProtector(_dir, useDpapi: false);
|
||||
var key = RoomCrypto.GenerateRoomKey();
|
||||
|
||||
Assert.True(protector.TryUnprotect(Convert.ToBase64String(key), out var recovered, out var wasLegacy));
|
||||
Assert.Equal(key, recovered);
|
||||
Assert.True(wasLegacy);
|
||||
|
||||
// The migration re-protects it; the upgraded value round-trips and is no longer legacy
|
||||
var upgraded = protector.Protect(recovered);
|
||||
Assert.True(protector.TryUnprotect(upgraded, out var recoveredAgain, out var stillLegacy));
|
||||
Assert.Equal(key, recoveredAgain);
|
||||
Assert.False(stillLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_Values_AreRejected()
|
||||
{
|
||||
var protector = new RoomKeyProtector(_dir, useDpapi: false);
|
||||
|
||||
Assert.False(protector.TryUnprotect("not base64 at all!!", out _, out _));
|
||||
Assert.False(protector.TryUnprotect(RoomKeyProtector.KeyFilePrefix + "not base64!!", out _, out _));
|
||||
// Valid base64 but not a 32-byte room key → not a usable legacy entry
|
||||
Assert.False(protector.TryUnprotect(Convert.ToBase64String([1, 2, 3]), out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFile_Lost_MakesStoredValuesUnreadable_NotThrow()
|
||||
{
|
||||
var protector = new RoomKeyProtector(_dir, useDpapi: false);
|
||||
var stored = protector.Protect(RoomCrypto.GenerateRoomKey());
|
||||
|
||||
File.Delete(Path.Combine(_dir, "roomkeys.key"));
|
||||
|
||||
// A fresh protector regenerates a different master key — the value must fail
|
||||
// cleanly (entry dropped, passphrase prompt recovers it), not throw
|
||||
var fresh = new RoomKeyProtector(_dir, useDpapi: false);
|
||||
Assert.False(fresh.TryUnprotect(stored, out _, out _));
|
||||
}
|
||||
}
|
||||
|
||||
public class RoomKeyStoreEnvelopeTests
|
||||
{
|
||||
// Note: without LoadForServer the store never touches the config file on disk —
|
||||
// these tests exercise the in-memory decision logic only.
|
||||
|
||||
private static RoomKeyStore NewStore() =>
|
||||
new(new RoomKeyProtector(Path.Combine(Path.GetTempPath(), "echohub-unused"), useDpapi: false));
|
||||
|
||||
[Fact]
|
||||
public void TryStoreFromEnvelope_FreshEnvelope_OverwritesStaleCachedKey()
|
||||
{
|
||||
var store = NewStore();
|
||||
var stale = RoomCrypto.GenerateRoomKey();
|
||||
store.StoreKey("vault", stale);
|
||||
|
||||
// Channel deleted and recreated under the same name → new room key, new envelope
|
||||
var fresh = RoomCrypto.GenerateRoomKey();
|
||||
var kek = RandomNumberGenerator.GetBytes(32);
|
||||
var wrapped = RoomCrypto.WrapRoomKey(fresh, kek);
|
||||
|
||||
Assert.True(store.TryStoreFromEnvelope("vault", wrapped, kek));
|
||||
Assert.True(store.TryGetKey("vault", out var current));
|
||||
Assert.Equal(fresh, current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryStoreFromEnvelope_WrongKek_KeepsCachedKey()
|
||||
{
|
||||
var store = NewStore();
|
||||
var cached = RoomCrypto.GenerateRoomKey();
|
||||
store.StoreKey("vault", cached);
|
||||
|
||||
var wrapped = RoomCrypto.WrapRoomKey(RoomCrypto.GenerateRoomKey(), RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
Assert.False(store.TryStoreFromEnvelope("vault", wrapped, RandomNumberGenerator.GetBytes(32)));
|
||||
Assert.True(store.TryGetKey("vault", out var current));
|
||||
Assert.Equal(cached, current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkChannelEncrypted_TracksAndUntracks()
|
||||
{
|
||||
var store = NewStore();
|
||||
|
||||
Assert.False(store.IsChannelEncrypted("vault"));
|
||||
|
||||
store.MarkChannelEncrypted("vault", true);
|
||||
Assert.True(store.IsChannelEncrypted("vault"));
|
||||
Assert.True(store.IsChannelEncrypted("VAULT")); // channel names are case-insensitive
|
||||
|
||||
store.MarkChannelEncrypted("vault", false);
|
||||
Assert.False(store.IsChannelEncrypted("vault"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StoreKey_MarksChannelEncrypted()
|
||||
{
|
||||
var store = NewStore();
|
||||
store.StoreKey("vault", RoomCrypto.GenerateRoomKey());
|
||||
|
||||
Assert.True(store.IsChannelEncrypted("vault"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user