mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
feat: implement end-to-end encryption for room keys with secure storage and migration support
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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