Merge pull request #56 from HueByte/dev_fixes_features_and_nightmares

feat: enhance IRC support with display name handling, private channel…
This commit is contained in:
Hue
2026-07-16 19:32:15 +02:00
committed by GitHub
24 changed files with 649 additions and 46 deletions
+1
View File
@@ -4,6 +4,7 @@ Release history for EchoHub.
## Releases ## Releases
- [v0.2.14](v0.2.14.md) - E2E Room Unlock Fixes, Encrypted Key Cache & IRC Gateway Polish
- [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions - [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions
- [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix - [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix
- [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata - [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata
+2
View File
@@ -1,5 +1,7 @@
- name: Overview - name: Overview
href: index.md href: index.md
- name: v0.2.14
href: v0.2.14.md
- name: v0.2.13 - name: v0.2.13
href: v0.2.13.md href: v0.2.13.md
- name: v0.2.12 - name: v0.2.12
+20
View File
@@ -0,0 +1,20 @@
# v0.2.14
A reliability and security pass over end-to-end encrypted rooms: locked channels now offer the passphrase prompt instead of dead-ending on the "rejoin to unlock" placeholder, the client can no longer be tricked into sending plaintext into an encrypted room, and cached room keys are encrypted at rest instead of sitting in the config as base64. Plus a set of IRC gateway fixes — decrypted image previews, display-name plumbing, and user/channel indicators.
## New Features
- **Room keys encrypted at rest** — the per-channel room keys cached so you don't retype a passphrase every launch are no longer stored as plain base64 in `config.json`. On Windows they're protected with DPAPI (current-user scope); on Linux/macOS with AES-GCM under a per-user key file created with `0600` permissions next to the config. Existing plain entries migrate to the encrypted format automatically on first load. The passphrase itself is never stored in any form.
- **`[irc]` tag in the users panel** — users online only through the IRC gateway are tagged `[irc]`, useful context since IRC clients lack encryption, attachments, and profiles. Someone also running the TUI shows untagged.
- **`~` marker for private channels** — the channel list now marks private (unlisted) channels with a trailing `~`, alongside the existing `*` for password-protected ones (`#room*~` when both apply).
## Bug Fixes
- **Locked encrypted channels now prompt for the passphrase.** Auto-joining your channels at connect silently entered end-to-end encrypted rooms you're a member of without running the unlock flow — on a new device (or after a cancelled prompt) the room showed only `[encrypted — rejoin this channel with its passphrase to unlock]` placeholders, and only a manual `/join` recovered it. Selecting the channel now offers the passphrase prompt; entering it unlocks history and live messages in place. Cancelling is remembered for the session so reselecting the channel doesn't nag — `/join` or trying to send always re-offers the prompt.
- **A stale cached room key no longer beats a fresh one.** If an encrypted channel was deleted and recreated under the same name, a client that still had the old key cached kept encrypting messages nobody else could read. Typing the passphrase on join now always adopts the key from the server's current envelope, replacing the stale cache.
- **IRC clients no longer get flooded with ciphertext for image messages.** The gateway forwarded image ASCII previews without stripping transport encryption, spamming IRC clients with one enormous `$ENC$v1$…` line per image. Previews are now decrypted before formatting, and any that still can't be read (e.g. end-to-end room ciphertext the server cannot decrypt) are skipped in favor of the plain `[Image: name] url` line.
- **Display names now show on chat messages.** Messages only carried the sender's username, so a configured display name appeared in the user list but not on the messages themselves. Live messages, history, and attachment messages now all carry it; mention and profile lookups stay keyed to the username.
## Security
- **No plaintext can leak into an encrypted room.** Previously, a client without the room key silently sent unencrypted text into an end-to-end encrypted channel (and other members saw it as a normal message, none the wiser it went over the wire readable by the server). All send paths — typed messages, staged file attachments, and URL sends — are now blocked while a room is locked: the client offers the unlock prompt, keeps staged files in the tray, and refuses to transmit until the key is present, with a hard guard at the connection layer as backstop.
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>0.2.13</Version> <Version>0.2.14</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn> <NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup> </PropertyGroup>
+108 -16
View File
@@ -35,6 +35,10 @@ public sealed class AppOrchestrator : IDisposable
private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase);
private readonly List<string> _stagedAttachments = []; 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 ClientConfig _config;
private readonly UserSession _session = new(); private readonly UserSession _session = new();
@@ -187,7 +191,8 @@ public sealed class AppOrchestrator : IDisposable
if (Uri.TryCreate(target, UriKind.Absolute, out var uri) if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
&& (uri.Scheme == "http" || uri.Scheme == "https")) && (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( InvokeUI(() => _mainWindow.ShowError(
"Sending by URL isn't available in encrypted channels — download the file and /send it instead.")); "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> /// </summary>
private void SendStagedMessage(string channel, string content) 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; var size = _config.DefaultAsciiSize;
RunAsync(async () => 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>(); var outgoing = new List<OutgoingAttachment>();
foreach (var path in staged) foreach (var path in staged)
outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size)); outgoing.Add(await BuildOutgoingAttachmentAsync(path, hasRoomKey ? roomKey : null, size));
@@ -417,6 +427,8 @@ public sealed class AppOrchestrator : IDisposable
try try
{ {
crypto = await _conn.Api!.GetChannelCryptoAsync(channelName); crypto = await _conn.Api!.GetChannelCryptoAsync(channelName);
if (crypto is not null)
_conn.RoomKeys.MarkChannelEncrypted(channelName, crypto.IsEncrypted);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -438,16 +450,20 @@ public sealed class AppOrchestrator : IDisposable
{ {
var outcome = await _conn.JoinChannelAsync(channelName, wirePassword); 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 // Re-fetch so history decrypts with the now-available room key
return await _conn.GetHistoryAsync(channelName); return await _conn.GetHistoryAsync(channelName);
} }
return await UnlockRoomKeyAsync(channelName, outcome); if (!_conn.RoomKeys.HasKey(channelName))
return await UnlockRoomKeyAsync(channelName, outcome);
} }
return outcome.History; return outcome.History;
@@ -484,12 +500,17 @@ public sealed class AppOrchestrator : IDisposable
var passphrase = await prompt.Task; var passphrase = await prompt.Task;
if (passphrase is null) 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); 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); 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> /// <summary>
/// Changes the current encrypted channel's passphrase: re-derives the join credential /// 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 /// 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 += () => _conn.Reconnected += () =>
{ {
lock (_channelUsersLock) _channelUsers.Clear(); lock (_channelUsersLock) _channelUsers.Clear();
lock (_declinedUnlocks) _declinedUnlocks.Clear();
RunAsync( RunAsync(
async () => await _conn.RejoinChannelsAsync(), async () => await _conn.RejoinChannelsAsync(),
"Failed to rejoin channels after reconnect"); "Failed to rejoin channels after reconnect");
@@ -970,6 +1052,7 @@ public sealed class AppOrchestrator : IDisposable
} }
_session.Username = result.Login.Username; _session.Username = result.Login.Username;
lock (_declinedUnlocks) _declinedUnlocks.Clear();
// Persisted last-read markers for this server — used to seed unread counts, // Persisted last-read markers for this server — used to seed unread counts,
// mention highlights, and "new messages" markers from the fetched histories. // mention highlights, and "new messages" markers from the fetched histories.
@@ -1073,9 +1156,12 @@ public sealed class AppOrchestrator : IDisposable
return; return;
} }
RunAsync( RunAsync(async () =>
async () => await _conn.SendMessageAsync(channelName, content), {
"Send failed"); if (!await EnsureRoomUnlockedForSendAsync(channelName))
return;
await _conn.SendMessageAsync(channelName, content);
}, "Send failed");
} }
private void HandleDeleteMessageRequested(Guid messageId) private void HandleDeleteMessageRequested(Guid messageId)
@@ -1112,6 +1198,12 @@ public sealed class AppOrchestrator : IDisposable
UpdateServerConfig(server => UpdateServerConfig(server =>
server.LeftChannels.RemoveAll(c => c.Equals(channelName, StringComparison.OrdinalIgnoreCase))); 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 try
{ {
+2 -1
View File
@@ -38,7 +38,8 @@ public class SavedServer
/// <summary> /// <summary>
/// Cached room content keys for end-to-end encrypted channels on this server, /// 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. /// user's machine — the server never sees them.
/// </summary> /// </summary>
public Dictionary<string, string> ChannelKeys { get; set; } = []; 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"); 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 // Load-mutate-save cycles run from both the UI thread and background tasks
// (token refresh, room keys, last-read checkpoints) — serialize file access. // (token refresh, room keys, last-read checkpoints) — serialize file access.
private static readonly Lock FileLock = new(); private static readonly Lock FileLock = new();
+1
View File
@@ -12,6 +12,7 @@
<PackageReference Include="Serilog" Version="4.3.1" /> <PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.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" /> <PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5043" />
</ItemGroup> </ItemGroup>
@@ -112,6 +112,11 @@ internal sealed class ConnectionManager : IAsyncDisposable
var channels = await _apiClient.GetChannelsAsync(); 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 // Join default channel + fetch history
onStatus("Joining channels..."); onStatus("Joining channels...");
_joinedChannels.Clear(); _joinedChannels.Clear();
@@ -296,7 +301,11 @@ internal sealed class ConnectionManager : IAsyncDisposable
connection.OnForceDisconnect += reason => ForceDisconnected?.Invoke(reason); connection.OnForceDisconnect += reason => ForceDisconnected?.Invoke(reason);
connection.OnMessageDeleted += (ch, id) => MessageDeleted?.Invoke(ch, id); connection.OnMessageDeleted += (ch, id) => MessageDeleted?.Invoke(ch, id);
connection.OnChannelNuked += ch => ChannelNuked?.Invoke(ch); 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.OnError += msg => Error?.Invoke(msg);
connection.OnConnectionStateChanged += status => ConnectionStatusChanged?.Invoke(status); connection.OnConnectionStateChanged += status => ConnectionStatusChanged?.Invoke(status);
connection.OnReconnected += () => Reconnected?.Invoke(); 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 sealed class EchoHubConnection : IAsyncDisposable
{ {
public const string LockedMessagePlaceholder = 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 ChannelPasswordRequiredException(channelName, result.Error ?? "Channel is password protected.");
throw new InvalidOperationException(result.Error ?? "Failed to join channel."); 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); 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 // Room layer first (end-to-end, server can't read), then transport encryption
if (_roomKeys.TryGetKey(channelName, out var roomKey)) if (_roomKeys.TryGetKey(channelName, out var roomKey))
content = RoomCrypto.EncryptText(content, 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); var encrypted = _encryption.Encrypt(content);
await _connection.InvokeAsync("SendMessage", channelName, encrypted); 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.Client.Config;
using EchoHub.Core.Security;
using Serilog; using Serilog;
namespace EchoHub.Client.Services; namespace EchoHub.Client.Services;
@@ -6,14 +7,28 @@ namespace EchoHub.Client.Services;
/// <summary> /// <summary>
/// Holds room content keys for end-to-end encrypted channels: in-memory for the /// 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) /// 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> /// </summary>
public sealed class RoomKeyStore public sealed class RoomKeyStore
{ {
private readonly Dictionary<string, byte[]> _keys = new(StringComparer.OrdinalIgnoreCase); 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 readonly Lock _lock = new();
private string? _serverUrl; 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> /// <summary>Binds the store to a server and loads that server's cached keys from config.</summary>
public void LoadForServer(string serverUrl) public void LoadForServer(string serverUrl)
{ {
@@ -21,21 +36,34 @@ public sealed class RoomKeyStore
{ {
_serverUrl = serverUrl; _serverUrl = serverUrl;
_keys.Clear(); _keys.Clear();
_encryptedChannels.Clear();
var server = FindServer(ConfigManager.Load(), serverUrl); var server = FindServer(ConfigManager.Load(), serverUrl);
if (server is null) return; 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) lock (_lock)
{ {
_keys[channelName] = key; _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) public void RemoveKey(string channelName)
{ {
lock (_lock) 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() public void Clear()
{ {
lock (_lock) lock (_lock)
{ {
_keys.Clear(); _keys.Clear();
_encryptedChannels.Clear();
_serverUrl = null; _serverUrl = null;
} }
} }
@@ -381,6 +381,9 @@ public sealed class ChatMessageManager
private List<ChatLine> FormatMessage(MessageDto message) private List<ChatLine> FormatMessage(MessageDto message)
{ {
var time = FormatTime(message.SentAt); var time = FormatTime(message.SentAt);
// Show the display name, but keep color + click identity keyed to the username
// so they stay consistent with the user list and profile lookups.
var senderName = message.SenderDisplayName ?? message.SenderUsername;
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor) var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor)
?? NickColorHelper.GetAttribute(message.SenderUsername); ?? NickColorHelper.GetAttribute(message.SenderUsername);
@@ -394,7 +397,7 @@ public sealed class ChatMessageManager
var displayContent = EmojiHelper.ReplaceEmoji(message.Content); var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
var contentLines = displayContent.Split('\n'); var contentLines = displayContent.Split('\n');
var header = HeaderSegments(time, message.SenderUsername, senderColor); var header = HeaderSegments(time, senderName, senderColor);
header.AddRange(ChatColors.SplitMentions(contentLines[0].TrimEnd('\r'))); header.AddRange(ChatColors.SplitMentions(contentLines[0].TrimEnd('\r')));
lines.Add(new ChatLine(header)); lines.Add(new ChatLine(header));
@@ -413,7 +416,7 @@ public sealed class ChatMessageManager
1 => $"[{attachments[0].Kind.ToString().ToLowerInvariant()}]", 1 => $"[{attachments[0].Kind.ToString().ToLowerInvariant()}]",
_ => $"[{attachments.Count} attachments]", _ => $"[{attachments.Count} attachments]",
}; };
var header = HeaderSegments(time, message.SenderUsername, senderColor); var header = HeaderSegments(time, senderName, senderColor);
header.Add(new(summary, null)); header.Add(new(summary, null));
lines.Add(new ChatLine(header)); lines.Add(new ChatLine(header));
} }
@@ -18,6 +18,7 @@ public class ChannelListSource : IListDataSource
private readonly Dictionary<string, int> _unreadCounts = []; private readonly Dictionary<string, int> _unreadCounts = [];
private readonly HashSet<string> _protectedChannels = []; private readonly HashSet<string> _protectedChannels = [];
private readonly HashSet<string> _mentionChannels = []; private readonly HashSet<string> _mentionChannels = [];
private readonly HashSet<string> _privateChannels = [];
private string _activeChannel = string.Empty; private string _activeChannel = string.Empty;
public event NotifyCollectionChangedEventHandler? CollectionChanged; public event NotifyCollectionChangedEventHandler? CollectionChanged;
@@ -32,7 +33,8 @@ public class ChannelListSource : IListDataSource
private static readonly Attribute MentionAttr = new(new Color(230, 140, 60), Color.None); private static readonly Attribute MentionAttr = new(new Color(230, 140, 60), Color.None);
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel, public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel,
IReadOnlySet<string>? protectedChannels = null, IReadOnlySet<string>? mentionChannels = null) IReadOnlySet<string>? protectedChannels = null, IReadOnlySet<string>? mentionChannels = null,
IReadOnlySet<string>? privateChannels = null)
{ {
_channelNames.Clear(); _channelNames.Clear();
_channelNames.AddRange(channels); _channelNames.AddRange(channels);
@@ -45,6 +47,9 @@ public class ChannelListSource : IListDataSource
_mentionChannels.Clear(); _mentionChannels.Clear();
if (mentionChannels is not null) if (mentionChannels is not null)
_mentionChannels.UnionWith(mentionChannels); _mentionChannels.UnionWith(mentionChannels);
_privateChannels.Clear();
if (privateChannels is not null)
_privateChannels.UnionWith(privateChannels);
_activeChannel = activeChannel; _activeChannel = activeChannel;
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0; MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
if (!SuspendCollectionChangedEvent) if (!SuspendCollectionChangedEvent)
@@ -67,8 +72,12 @@ public class ChannelListSource : IListDataSource
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal); var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus); var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
var prefix = isActive ? "> " : " "; var prefix = isActive ? "> " : " ";
// Trailing * marks password-protected (+k) channels // Trailing * marks password-protected (+k) channels; ~ marks private (unlisted) ones
var channelText = _protectedChannels.Contains(name) ? $"#{name}*" : $"#{name}"; var channelText = $"#{name}";
if (_protectedChannels.Contains(name))
channelText += "*";
if (_privateChannels.Contains(name))
channelText += "~";
var badge = hasUnread ? $" ({unread})" : ""; var badge = hasUnread ? $" ({unread})" : "";
// Resolve Transparent backgrounds to the view's actual background // Resolve Transparent backgrounds to the view's actual background
+8 -1
View File
@@ -1317,8 +1317,11 @@ public sealed partial class MainWindow : Runnable
/// </summary> /// </summary>
private void RefreshChannelList() private void RefreshChannelList()
{ {
var privateChannels = _channelNames
.Where(n => _channelPublic.TryGetValue(n, out var isPublic) && !isPublic)
.ToHashSet();
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel, _channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel,
_channelProtected, _messageManager.MentionChannels); _channelProtected, _messageManager.MentionChannels, privateChannels);
_channelList.Source = _channelListSource; _channelList.Source = _channelListSource;
// Restore selection to current channel // Restore selection to current channel
@@ -1394,6 +1397,10 @@ public sealed partial class MainWindow : Runnable
var text = roleTag.Length > 0 var text = roleTag.Length > 0
? $"{statusIcon} {roleTag} {name}" ? $"{statusIcon} {roleTag} {name}"
: $"{statusIcon} {name}"; : $"{statusIcon} {name}";
// Users connected only via the IRC gateway get a tag — they lack client features
// (encryption, attachments, profiles), which is useful context in conversation.
if (u.IsIrc)
text += " [irc]";
// Fall back to the deterministic per-nick palette so user-list colors // Fall back to the deterministic per-nick palette so user-list colors
// match the same user's messages in chat. // match the same user's messages in chat.
var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor) var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor)
@@ -4,6 +4,12 @@ public static class HubConstants
{ {
public const string ChatHubPath = "/hubs/chat"; public const string ChatHubPath = "/hubs/chat";
public const string DefaultChannel = "general"; public const string DefaultChannel = "general";
/// <summary>
/// Connection-id prefix for IRC gateway connections. The presence tracker uses it to
/// tell IRC-only users apart from native (SignalR) clients.
/// </summary>
public const string IrcConnectionIdPrefix = "irc-";
public const int DefaultHistoryCount = 100; public const int DefaultHistoryCount = 100;
public const int MaxMessageLength = 2000; public const int MaxMessageLength = 2000;
public const int MaxImageSizeBytes = 10 * 1024 * 1024; // 10 MB public const int MaxImageSizeBytes = 10 * 1024 * 1024; // 10 MB
+2 -1
View File
@@ -10,7 +10,8 @@ public record MessageDto(
string ChannelName, string ChannelName,
DateTimeOffset SentAt, DateTimeOffset SentAt,
List<AttachmentDto>? Attachments = null, List<AttachmentDto>? Attachments = null,
List<EmbedDto>? Embeds = null); List<EmbedDto>? Embeds = null,
string? SenderDisplayName = null);
/// <summary> /// <summary>
/// A file attached to a message. <see cref="AsciiPreview"/> holds the color-tag art for /// A file attached to a message. <see cref="AsciiPreview"/> holds the color-tag art for
+2 -1
View File
@@ -30,6 +30,7 @@ public record UserPresenceDto(
string? NicknameColor, string? NicknameColor,
UserStatus Status, UserStatus Status,
string? StatusMessage, string? StatusMessage,
ServerRole Role); ServerRole Role,
bool IsIrc = false);
public record AvatarUploadResponse(string AvatarAscii); public record AvatarUploadResponse(string AvatarAscii);
@@ -1,5 +1,6 @@
using System.Net.Sockets; using System.Net.Sockets;
using System.Text; using System.Text;
using EchoHub.Core.Constants;
namespace EchoHub.Server.Irc; namespace EchoHub.Server.Irc;
@@ -14,7 +15,7 @@ public sealed class IrcClientConnection : IAsyncDisposable
private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly SemaphoreSlim _writeLock = new(1, 1);
// Connection identity // Connection identity
public string ConnectionId { get; } = $"irc-{Guid.NewGuid()}"; public string ConnectionId { get; } = $"{HubConstants.IrcConnectionIdPrefix}{Guid.NewGuid()}";
// Registration state // Registration state
public string? Nickname { get; set; } public string? Nickname { get; set; }
@@ -308,7 +308,8 @@ public class ChannelsController : ControllerBase
sender?.NicknameColor, sender?.NicknameColor,
channelName, channelName,
message.SentAt, message.SentAt,
attachmentDtos); attachmentDtos,
SenderDisplayName: sender?.DisplayName);
await _chatService.BroadcastMessageAsync(channelName, messageDto); await _chatService.BroadcastMessageAsync(channelName, messageDto);
@@ -444,7 +445,8 @@ public class ChannelsController : ControllerBase
sender?.NicknameColor, sender?.NicknameColor,
channelName, channelName,
message.SentAt, message.SentAt,
[new AttachmentDto(AttachmentKind.Image, attachmentUrl, fileName, imageBytes.Length, _encryption.Encrypt(preview))]); [new AttachmentDto(AttachmentKind.Image, attachmentUrl, fileName, imageBytes.Length, _encryption.Encrypt(preview))],
SenderDisplayName: sender?.DisplayName);
await _chatService.BroadcastMessageAsync(channelName, messageDto); await _chatService.BroadcastMessageAsync(channelName, messageDto);
+17 -9
View File
@@ -121,7 +121,8 @@ public class ChatService : IChatService
{ {
presence = new UserPresenceDto( presence = new UserPresenceDto(
user.Username, user.DisplayName, user.NicknameColor, user.Username, user.DisplayName, user.NicknameColor,
user.Status, user.StatusMessage, user.Role); user.Status, user.StatusMessage, user.Role,
_presenceTracker.IsIrcOnly(user.Username));
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -236,7 +237,8 @@ public class ChatService : IChatService
sender?.NicknameColor, sender?.NicknameColor,
channelName, channelName,
message.SentAt, message.SentAt,
Embeds: embeds); Embeds: embeds,
SenderDisplayName: sender?.DisplayName);
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
@@ -279,7 +281,8 @@ public class ChatService : IChatService
user.NicknameColor, user.NicknameColor,
status, status,
statusMessage, statusMessage,
user.Role); user.Role,
_presenceTracker.IsIrcOnly(user.Username));
var channels = _presenceTracker.GetChannelsForUser(username); var channels = _presenceTracker.GetChannelsForUser(username);
await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence)); await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
@@ -295,16 +298,20 @@ public class ChatService : IChatService
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
return await db.Users var users = await db.Users
.Where(u => onlineUsernames.Contains(u.Username) && u.Status != UserStatus.Invisible) .Where(u => onlineUsernames.Contains(u.Username) && u.Status != UserStatus.Invisible)
.Select(u => new UserPresenceDto( .ToListAsync();
// IsIrcOnly comes from the in-memory tracker, so map outside the EF query
return users.Select(u => new UserPresenceDto(
u.Username, u.Username,
u.DisplayName, u.DisplayName,
u.NicknameColor, u.NicknameColor,
u.Status, u.Status,
u.StatusMessage, u.StatusMessage,
u.Role)) u.Role,
.ToListAsync(); _presenceTracker.IsIrcOnly(u.Username)))
.ToList();
} }
public Task BroadcastMessageAsync(string channelName, MessageDto message) public Task BroadcastMessageAsync(string channelName, MessageDto message)
@@ -380,7 +387,7 @@ public class ChatService : IChatService
.Join(db.Users, .Join(db.Users,
m => m.SenderUserId, m => m.SenderUserId,
u => u.Id, u => u.Id,
(m, u) => new { m, u.NicknameColor }) (m, u) => new { m, u.NicknameColor, u.DisplayName })
.ToListAsync(); .ToListAsync();
raw.Reverse(); raw.Reverse();
@@ -448,7 +455,8 @@ public class ChatService : IChatService
channelName, channelName,
x.m.SentAt, x.m.SentAt,
attachments, attachments,
embeds)); embeds,
x.DisplayName));
} }
// Lazily delete the pruned messages (+ their attachment rows) as they're encountered. // Lazily delete the pruned messages (+ their attachment rows) as they're encountered.
@@ -1,4 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using EchoHub.Core.Constants;
namespace EchoHub.Server.Services; namespace EchoHub.Server.Services;
@@ -174,6 +175,20 @@ public class PresenceTracker
return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0; return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0;
} }
/// <summary>
/// True when the user is online exclusively through the IRC gateway. A user who also has a
/// native client connected has full features, so they don't count as IRC-only.
/// </summary>
public bool IsIrcOnly(string username)
{
lock (_lock)
{
return _userConnections.TryGetValue(username, out var connections)
&& connections.Count > 0
&& connections.All(c => c.StartsWith(HubConstants.IrcConnectionIdPrefix, StringComparison.Ordinal));
}
}
public int GetOnlineUserCount() public int GetOnlineUserCount()
{ {
return _userConnections.Count; return _userConnections.Count;
+45
View File
@@ -1,3 +1,4 @@
using EchoHub.Core.Constants;
using EchoHub.Server.Services; using EchoHub.Server.Services;
using Xunit; using Xunit;
@@ -65,4 +66,48 @@ public class PresenceTrackerTests
Assert.Contains("general", channels); Assert.Contains("general", channels);
Assert.Contains("random", channels); Assert.Contains("random", channels);
} }
[Fact]
public void IsIrcOnly_AllConnectionsIrc_ReturnsTrue()
{
var tracker = new PresenceTracker();
tracker.UserConnected($"{HubConstants.IrcConnectionIdPrefix}conn1", Guid.NewGuid(), "alice");
Assert.True(tracker.IsIrcOnly("alice"));
}
[Fact]
public void IsIrcOnly_NativeConnection_ReturnsFalse()
{
var tracker = new PresenceTracker();
tracker.UserConnected("conn1", Guid.NewGuid(), "alice");
Assert.False(tracker.IsIrcOnly("alice"));
}
[Fact]
public void IsIrcOnly_MixedConnections_ReturnsFalse()
{
var tracker = new PresenceTracker();
var userId = Guid.NewGuid();
tracker.UserConnected($"{HubConstants.IrcConnectionIdPrefix}conn1", userId, "alice");
tracker.UserConnected("conn2", userId, "alice");
Assert.False(tracker.IsIrcOnly("alice"));
}
[Fact]
public void IsIrcOnly_OfflineUser_ReturnsFalse()
{
var tracker = new PresenceTracker();
Assert.False(tracker.IsIrcOnly("nobody"));
}
[Fact]
public void IsIrcOnly_NativeConnectionDisconnects_BecomesTrue()
{
var tracker = new PresenceTracker();
var userId = Guid.NewGuid();
tracker.UserConnected($"{HubConstants.IrcConnectionIdPrefix}conn1", userId, "alice");
tracker.UserConnected("conn2", userId, "alice");
tracker.UserDisconnected("conn2");
Assert.True(tracker.IsIrcOnly("alice"));
}
} }
+168
View File
@@ -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"));
}
}