mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
feat: Implement end-to-end encryption for channels
- Added EncryptionSalt and WrappedRoomKey properties to Channel model. - Introduced RoomCrypto class for client-side encryption and decryption. - Updated ChannelService to handle encrypted channels, including creation and rekeying. - Modified ChannelsController to expose crypto metadata and rekey functionality. - Enhanced IrcCommandHandler to block joining encrypted channels over IRC. - Updated database schema with migration for new encryption fields. - Refactored file validation and image processing services to accommodate encrypted channels. - Added unit tests for RoomCrypto functionality and updated existing tests for channel services.
This commit is contained in:
@@ -99,7 +99,8 @@ graph TD
|
||||
- **Slash commands** — `/join`, `/send`, `/status`, `/theme`, etc.
|
||||
- **Colored nicknames** — pick your hex color, express yourself
|
||||
- **Clickable everything** — usernames, @mentions, #channels — just press Enter
|
||||
- **File/image sharing** — local files or URLs; drag & drop a file onto the terminal to send it
|
||||
- **File/image sharing** — local files or URLs; drag & drop a file onto the terminal to send it; save the original behind any ASCII-art image
|
||||
- **End-to-end encrypted rooms** — password-protected channels are encrypted with a passphrase-derived key that never reaches the server, so not even the server owner can read messages or files (they can still see counts and storage size)
|
||||
- **Multi-server** — save and switch between servers
|
||||
- **Auto-reconnect** — drops happen, it rejoins your channels automatically
|
||||
- **Auto-updater** — updates in-place with automatic rollback if something goes wrong
|
||||
@@ -224,7 +225,8 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
||||
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
| `/join <channel> [password]` | Join a channel (password for protected channels) |
|
||||
| `/join <channel> [password]` | Join a channel (passphrase for encrypted channels) |
|
||||
| `/passwd <old> <new>` | Change the current encrypted channel's passphrase (creator only) |
|
||||
| `/leave` | Leave current channel |
|
||||
| `/topic <text>` | Set channel topic (creator only) |
|
||||
| `/send <file or URL>` | Upload a file or image |
|
||||
|
||||
@@ -4,6 +4,7 @@ Release history for EchoHub.
|
||||
|
||||
## Releases
|
||||
|
||||
- [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.10](v0.2.10.md) - Command Palette, Infinite History Scroll & Auto-Updater Fixes
|
||||
- [v0.2.9](v0.2.9.md) - Install Script & Chocolatey Fixes
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
- name: Overview
|
||||
href: index.md
|
||||
- name: v0.2.12
|
||||
href: v0.2.12.md
|
||||
- name: v0.2.11
|
||||
href: v0.2.11.md
|
||||
- name: v0.2.10
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# v0.2.12
|
||||
|
||||
Private channels are now genuinely private: password-protected channels are end-to-end encrypted, so the server (and its operators) can gate joins and measure storage but cannot read message or file contents. The IRC gateway grows real MODE/TOPIC support and channel keys, and the client gets image "save original", a transparent-light theme, drag-and-drop file sending, Ctrl+V paste, and a fix for the intermittent Ctrl+W crash.
|
||||
|
||||
## New Features
|
||||
|
||||
- **End-to-end encrypted channels** — creating a channel with a password now provisions a zero-knowledge room:
|
||||
- The passphrase never leaves the client. It derives (PBKDF2-SHA256, 210k iterations) two keys: an *auth key* sent to the server as the join credential, and a *key-encryption key* that never leaves the machine.
|
||||
- A random room content key encrypts every message and file with AES-256-GCM. The server only ever stores the room key *wrapped* under the passphrase, so it can gate joins and report a channel's message count, storage size, and attachments — but cannot decrypt any of it. Even the server owner cannot read a private room's contents.
|
||||
- Members' clients cache the derived room key locally (in the per-server config, like saved sessions) so the passphrase isn't retyped every launch; joining on a new device prompts for it once.
|
||||
- Change the passphrase with `/passwd <old> <new>` (channel creator only). The room key is re-wrapped, not rotated, so **existing history stays readable** and members who join later with the new passphrase can still read older messages.
|
||||
- Files and images are encrypted client-side before upload; for images the ASCII-art preview is rendered on the client and stored room-encrypted too. Sending images by URL is disabled in encrypted channels (the server can't fetch-and-render without the key).
|
||||
- End-to-end encrypted channels cannot be joined over the IRC gateway (that would require the server to hold the room key) — IRC `JOIN` returns `475` directing users to the EchoHub client.
|
||||
- Password-protected channels — set an optional password when creating a channel (masked field in the Create Channel dialog, `password` on `POST /api/channels`). Passwords are BCrypt-hashed server-side; the join gate applies on first join only (existing members and the creator are unaffected). Protected channels show a `*` marker in the channel list and `+k` in the status bar
|
||||
- Save original images — image messages now show a clickable "[↓ save original]" line under the ASCII-art preview that downloads the full-resolution original to your Downloads folder (decrypting locally in encrypted channels)
|
||||
- `/join <channel> [password]` — join protected channels inline, or let the client prompt: joining a protected channel without a password opens a masked prompt that re-prompts on a wrong password
|
||||
- IRC channel keys — `JOIN #room <key>` works against room passwords (RFC 1459 comma-paired key lists supported); keyless or wrong-key joins get `475 ERR_BADCHANNELKEY`
|
||||
- IRC `MODE` implemented — `MODE #chan` reports `+k`/`+`, `MODE #chan +k <key>` sets and `-k` clears the room password (channel creator or admin only), ban-list probes get a clean empty reply, and `CHANMODES` is advertised in ISUPPORT
|
||||
- IRC `TOPIC` set support — the channel creator can change the topic from IRC; the change broadcasts to connected TUI clients (previously topic changes were rejected with a stub error)
|
||||
- Drag & drop file sending — dropping a file (image, audio, anything) onto the terminal detects the pasted path and sends it through `/send` automatically, including multiple files at once
|
||||
- Ctrl+V pastes into the message input (previously paste was only available via the right-click menu); Ctrl+Y works as an alias
|
||||
- New `TransparentLight` theme — dark characters on a transparent background, for light terminal color schemes (`/theme transparentlight`)
|
||||
- Timestamps in messages are now aware of the current culture and display the short time pattern for today's messages and the short date+time pattern for older messages.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Fixed intermittent crash on Ctrl+W — Terminal.Gui binds Ctrl+W to clipboard-cut, and Windows clipboard contention (another app holding the clipboard) threw an unhandled `Win32Exception` that took the app down. Ctrl+W now deletes the previous word (readline behavior, no clipboard), and all clipboard shortcuts (Ctrl+X/C/V/Y) are guarded so transient clipboard failures log a warning instead of crashing
|
||||
- Fixed emoji shortcode replacement permanently disabling itself if a cursor update threw mid-replacement
|
||||
- IRC `LIST` no longer leaks private channels; protected channels are marked `[+k]`
|
||||
|
||||
## API Changes
|
||||
|
||||
- `ChannelDto` gains `isProtected` and `isEncrypted`; `CreateChannelRequest` gains optional `password`, `encryptionSalt`, and `wrappedRoomKey`; SignalR `JoinChannel` takes an optional second `password` argument and `JoinChannelResult` gains `passwordRequired`, `encryptionSalt`, and `wrappedRoomKey` (older clients must update to join over SignalR)
|
||||
- New endpoints: `GET /api/channels/{channel}/crypto` (public crypto metadata — salt only, never the wrapped key) and `POST /api/channels/{channel}/rekey` (creator-only passphrase change)
|
||||
- The upload endpoint accepts `type` and `content` form fields for encrypted channels, where the client supplies the declared message type and room-encrypted content
|
||||
- `ImageToAsciiService` and `FileValidationHelper` moved from `EchoHub.Server` to `EchoHub.Core` so the client can render ASCII art and detect file types for encrypted uploads
|
||||
- New EF migrations `AddChannelPasswordHash` and `AddChannelEncryptionEnvelope` (applied automatically on server start)
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
- [ ] fix the chat trailing; when user scrolls up, and somebody sends a message – the chat instantly "teleports" to the very bottom
|
||||
- [x] disable the autorun of files (maybe keep for mp4? gotta do some sec research on it)
|
||||
- [x] when user creates a new channel, he gets moved to that channel; but the userlist does not refresh the state on that – it refreshes when user re-enters the channel again
|
||||
- [x] password protected rooms
|
||||
- [x] password protected rooms (end-to-end encrypted — server cannot read contents)
|
||||
- [ ] better audio lib, current one (NetCoreAudio) does not support seek or other audio actions
|
||||
- [ ] Use options pattern for both client & server
|
||||
- ref: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?view=aspnetcore-10.0
|
||||
|
||||
@@ -8,6 +8,8 @@ using EchoHub.Client.UI.Dialogs;
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Core.Security;
|
||||
using EchoHub.Core.Services;
|
||||
using Serilog;
|
||||
using Terminal.Gui.App;
|
||||
using Terminal.Gui.Views;
|
||||
@@ -88,6 +90,7 @@ public sealed class AppOrchestrator : IDisposable
|
||||
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
|
||||
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
||||
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
||||
_mainWindow.OnImageSaveRequested += HandleImageSaveRequested;
|
||||
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
||||
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
||||
_mainWindow.OnUserProfileRequested += HandleViewProfile;
|
||||
@@ -109,6 +112,7 @@ public sealed class AppOrchestrator : IDisposable
|
||||
_commandHandler.OnOpenProfile += HandleCmdOpenProfile;
|
||||
_commandHandler.OnOpenServers += HandleCmdOpenServers;
|
||||
_commandHandler.OnJoinChannel += HandleCmdJoinChannel;
|
||||
_commandHandler.OnChangeRoomPassword += HandleCmdChangeRoomPassword;
|
||||
_commandHandler.OnLeaveChannel += HandleCmdLeaveChannel;
|
||||
_commandHandler.OnSetTopic += HandleCmdSetTopic;
|
||||
_commandHandler.OnListUsers += HandleCmdListUsers;
|
||||
@@ -167,11 +171,24 @@ public sealed class AppOrchestrator : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
var hasRoomKey = _conn.RoomKeys.TryGetKey(channel, out var roomKey);
|
||||
|
||||
if (Uri.TryCreate(target, UriKind.Absolute, out var uri)
|
||||
&& (uri.Scheme == "http" || uri.Scheme == "https"))
|
||||
{
|
||||
if (hasRoomKey)
|
||||
{
|
||||
InvokeUI(() => _mainWindow.ShowError(
|
||||
"Sending by URL isn't available in encrypted channels — download the file and /send it instead."));
|
||||
return;
|
||||
}
|
||||
|
||||
await _conn.Api!.SendUrlAsync(channel, target, size);
|
||||
}
|
||||
else if (hasRoomKey)
|
||||
{
|
||||
await UploadEncryptedFileAsync(channel, target, size, roomKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
await using var stream = File.OpenRead(target);
|
||||
@@ -186,6 +203,46 @@ public sealed class AppOrchestrator : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upload into an end-to-end encrypted channel: the blob is encrypted with the room
|
||||
/// key before it leaves this machine, and for images the ASCII preview is rendered
|
||||
/// locally and sent room-encrypted — the server never sees image or file contents.
|
||||
/// </summary>
|
||||
private async Task UploadEncryptedFileAsync(string channel, string path, string? size, byte[] roomKey)
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
var bytes = await File.ReadAllBytesAsync(path);
|
||||
|
||||
string declaredType;
|
||||
string plainContent;
|
||||
using (var ms = new MemoryStream(bytes))
|
||||
{
|
||||
if (FileValidationHelper.IsValidImage(ms))
|
||||
{
|
||||
declaredType = "image";
|
||||
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
||||
ms.Position = 0;
|
||||
plainContent = new ImageToAsciiService().ConvertToAscii(ms, w, h);
|
||||
}
|
||||
else if (FileValidationHelper.IsAudioFile(fileName))
|
||||
{
|
||||
declaredType = "audio";
|
||||
plainContent = fileName;
|
||||
}
|
||||
else
|
||||
{
|
||||
declaredType = "file";
|
||||
plainContent = fileName;
|
||||
}
|
||||
}
|
||||
|
||||
var encryptedContent = RoomCrypto.EncryptText(plainContent, roomKey);
|
||||
var encryptedBlob = RoomCrypto.EncryptBytes(bytes, roomKey);
|
||||
|
||||
await using var blobStream = new MemoryStream(encryptedBlob);
|
||||
await _conn.Api!.UploadFileAsync(channel, blobStream, fileName, size, declaredType, encryptedContent);
|
||||
}
|
||||
|
||||
private async Task HandleCmdSetAvatar(string target)
|
||||
{
|
||||
if (!_conn.IsAuthenticated) return;
|
||||
@@ -241,16 +298,51 @@ public sealed class AppOrchestrator : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Joins a channel, prompting for a password when the server requires one and
|
||||
/// re-prompting on a wrong password. Returns the channel history, or null if
|
||||
/// the user cancelled the prompt.
|
||||
/// re-prompting on a wrong password. For end-to-end encrypted channels the typed
|
||||
/// passphrase never goes to the server — a PBKDF2-derived auth key is sent instead,
|
||||
/// and the room content key is unwrapped locally. Returns the channel history,
|
||||
/// or null if the user cancelled the prompt.
|
||||
/// </summary>
|
||||
private async Task<List<MessageDto>?> JoinChannelWithPasswordPromptAsync(string channelName, string? password)
|
||||
{
|
||||
ChannelCryptoDto? crypto = null;
|
||||
try
|
||||
{
|
||||
crypto = await _conn.Api!.GetChannelCryptoAsync(channelName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug(ex, "Crypto metadata unavailable for {Channel}", channelName);
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
byte[]? kek = null;
|
||||
var wirePassword = password;
|
||||
if (password is not null && crypto is { IsEncrypted: true, EncryptionSalt: not null })
|
||||
{
|
||||
var derived = RoomCrypto.DeriveKeys(password, Convert.FromBase64String(crypto.EncryptionSalt));
|
||||
wirePassword = derived.AuthKeyHex;
|
||||
kek = derived.KeyEncryptionKey;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _conn.JoinChannelAsync(channelName, password);
|
||||
var outcome = await _conn.JoinChannelAsync(channelName, wirePassword);
|
||||
|
||||
if (outcome.WrappedRoomKey is not null && !_conn.RoomKeys.HasKey(channelName))
|
||||
{
|
||||
if (kek is not null && RoomCrypto.TryUnwrapRoomKey(outcome.WrappedRoomKey, kek, out var roomKey))
|
||||
{
|
||||
_conn.RoomKeys.StoreKey(channelName, roomKey);
|
||||
// Re-fetch so history decrypts with the now-available room key
|
||||
return await _conn.GetHistoryAsync(channelName);
|
||||
}
|
||||
|
||||
return await UnlockRoomKeyAsync(channelName, outcome);
|
||||
}
|
||||
|
||||
return outcome.History;
|
||||
}
|
||||
catch (ChannelPasswordRequiredException ex)
|
||||
{
|
||||
@@ -264,6 +356,85 @@ public sealed class AppOrchestrator : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Member of an encrypted channel without a cached room key (e.g. a new device):
|
||||
/// prompt for the passphrase until the room key unwraps or the user gives up.
|
||||
/// </summary>
|
||||
private async Task<List<MessageDto>?> UnlockRoomKeyAsync(string channelName, JoinOutcome outcome)
|
||||
{
|
||||
if (outcome.EncryptionSalt is null || outcome.WrappedRoomKey is null)
|
||||
return outcome.History;
|
||||
|
||||
var salt = Convert.FromBase64String(outcome.EncryptionSalt);
|
||||
var message = "Enter the passphrase to unlock messages.";
|
||||
|
||||
while (true)
|
||||
{
|
||||
var prompt = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var promptMessage = message;
|
||||
InvokeUI(() => prompt.SetResult(ChannelPasswordDialog.Show(_app, channelName, promptMessage)));
|
||||
|
||||
var passphrase = await prompt.Task;
|
||||
if (passphrase is null)
|
||||
return outcome.History; // stays locked; placeholders render instead of content
|
||||
|
||||
var derived = RoomCrypto.DeriveKeys(passphrase, salt);
|
||||
if (RoomCrypto.TryUnwrapRoomKey(outcome.WrappedRoomKey, derived.KeyEncryptionKey, out var roomKey))
|
||||
{
|
||||
_conn.RoomKeys.StoreKey(channelName, roomKey);
|
||||
return await _conn.GetHistoryAsync(channelName);
|
||||
}
|
||||
|
||||
message = "Wrong passphrase — try again.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes the current encrypted channel's passphrase: re-derives the join credential
|
||||
/// and re-wraps the cached room content key under the new passphrase. History is
|
||||
/// never re-encrypted — the room key itself doesn't change.
|
||||
/// </summary>
|
||||
private async Task HandleCmdChangeRoomPassword(string oldPassphrase, string newPassphrase)
|
||||
{
|
||||
if (!_conn.IsAuthenticated || !_conn.IsConnected) return;
|
||||
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (string.IsNullOrEmpty(channel)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var crypto = await _conn.Api!.GetChannelCryptoAsync(channel);
|
||||
if (crypto is not { IsEncrypted: true } || crypto.EncryptionSalt is null)
|
||||
{
|
||||
InvokeUI(() => _mainWindow.ShowError($"#{channel} is not an end-to-end encrypted channel."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_conn.RoomKeys.TryGetKey(channel, out var roomKey))
|
||||
{
|
||||
InvokeUI(() => _mainWindow.ShowError("Unlock this channel first (rejoin it with its passphrase), then retry."));
|
||||
return;
|
||||
}
|
||||
|
||||
var oldDerived = RoomCrypto.DeriveKeys(oldPassphrase, Convert.FromBase64String(crypto.EncryptionSalt));
|
||||
var newSalt = RoomCrypto.GenerateSalt();
|
||||
var newDerived = RoomCrypto.DeriveKeys(newPassphrase, newSalt);
|
||||
|
||||
await _conn.Api!.RekeyChannelAsync(channel, new RekeyChannelRequest(
|
||||
oldDerived.AuthKeyHex,
|
||||
newDerived.AuthKeyHex,
|
||||
Convert.ToBase64String(newSalt),
|
||||
RoomCrypto.WrapRoomKey(roomKey, newDerived.KeyEncryptionKey)));
|
||||
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(channel,
|
||||
"Passphrase changed. History stays readable; new members and new devices need the new passphrase."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
InvokeUI(() => _mainWindow.ShowError($"Passphrase change failed: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCmdLeaveChannel()
|
||||
{
|
||||
if (!_conn.IsConnected) return;
|
||||
@@ -1019,10 +1190,35 @@ public sealed class AppOrchestrator : IDisposable
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
var channel = await _conn.Api!.CreateChannelAsync(result.Name, result.Topic, result.IsPublic, result.Password);
|
||||
// Password rooms are end-to-end encrypted: derive the join credential and
|
||||
// wrap a fresh room content key locally — the passphrase never leaves here.
|
||||
string? wirePassword = null, saltB64 = null, wrappedKey = null;
|
||||
byte[]? roomKey = null;
|
||||
if (result.Password is not null)
|
||||
{
|
||||
if (result.Password.Length < ValidationConstants.MinChannelPasswordLength)
|
||||
{
|
||||
InvokeUI(() => _mainWindow.ShowError(
|
||||
$"Channel password must be at least {ValidationConstants.MinChannelPasswordLength} characters."));
|
||||
return;
|
||||
}
|
||||
|
||||
var salt = RoomCrypto.GenerateSalt();
|
||||
var derived = RoomCrypto.DeriveKeys(result.Password, salt);
|
||||
roomKey = RoomCrypto.GenerateRoomKey();
|
||||
wirePassword = derived.AuthKeyHex;
|
||||
saltB64 = Convert.ToBase64String(salt);
|
||||
wrappedKey = RoomCrypto.WrapRoomKey(roomKey, derived.KeyEncryptionKey);
|
||||
}
|
||||
|
||||
var channel = await _conn.Api!.CreateChannelAsync(
|
||||
result.Name, result.Topic, result.IsPublic, wirePassword, saltB64, wrappedKey);
|
||||
if (channel is null) return;
|
||||
|
||||
var history = await _conn.JoinChannelAsync(channel.Name);
|
||||
if (roomKey is not null)
|
||||
_conn.RoomKeys.StoreKey(channel.Name, roomKey);
|
||||
|
||||
var history = (await _conn.JoinChannelAsync(channel.Name)).History;
|
||||
|
||||
InvokeUI(() =>
|
||||
{
|
||||
@@ -1084,11 +1280,59 @@ public sealed class AppOrchestrator : IDisposable
|
||||
RunAsync(async () =>
|
||||
{
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
||||
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
||||
InvokeUI(() => AudioPlayerDialog.Show(_app, _audioPlayback, tempPath, fileName));
|
||||
}, "Failed to play audio");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads an attachment to a temp file, decrypting it locally when the current
|
||||
/// channel is end-to-end encrypted (the server stores those blobs as ciphertext).
|
||||
/// </summary>
|
||||
private async Task<string> DownloadAttachmentAsync(string attachmentUrl, string fileName)
|
||||
{
|
||||
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
||||
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (!string.IsNullOrEmpty(channel) && _conn.RoomKeys.TryGetKey(channel, out var roomKey))
|
||||
{
|
||||
try
|
||||
{
|
||||
var blob = await File.ReadAllBytesAsync(tempPath);
|
||||
await File.WriteAllBytesAsync(tempPath, RoomCrypto.DecryptBytes(blob, roomKey));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Attachment {File} did not decrypt with the room key — keeping raw bytes", fileName);
|
||||
}
|
||||
}
|
||||
|
||||
return tempPath;
|
||||
}
|
||||
|
||||
private void HandleImageSaveRequested(string attachmentUrl, string fileName)
|
||||
{
|
||||
if (!_conn.IsAuthenticated) return;
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
||||
|
||||
var downloads = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
||||
Directory.CreateDirectory(downloads);
|
||||
|
||||
var stem = Path.GetFileNameWithoutExtension(fileName);
|
||||
var ext = Path.GetExtension(fileName);
|
||||
var destination = Path.Combine(downloads, fileName);
|
||||
for (var i = 1; File.Exists(destination); i++)
|
||||
destination = Path.Combine(downloads, $"{stem} ({i}){ext}");
|
||||
|
||||
File.Move(tempPath, destination);
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Image saved to: {destination}"));
|
||||
}, "Failed to save image");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// File extensions considered safe to open with the system default application.
|
||||
/// Everything else is downloaded only — never auto-opened via UseShellExecute.
|
||||
@@ -1106,7 +1350,7 @@ public sealed class AppOrchestrator : IDisposable
|
||||
RunAsync(async () =>
|
||||
{
|
||||
InvokeUI(() => _messageManager.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
|
||||
var tempPath = await _conn.Api!.DownloadFileToTempAsync(attachmentUrl, fileName);
|
||||
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
||||
|
||||
var ext = Path.GetExtension(fileName);
|
||||
if (SafeOpenExtensions.Contains(ext))
|
||||
|
||||
@@ -14,6 +14,7 @@ public class CommandHandler
|
||||
public event Func<string?, Task>? OnOpenProfile;
|
||||
public event Func<Task>? OnOpenServers;
|
||||
public event Func<string, string?, Task>? OnJoinChannel;
|
||||
public event Func<string, string, Task>? OnChangeRoomPassword;
|
||||
public event Func<Task>? OnLeaveChannel;
|
||||
public event Func<string, Task>? OnSetTopic;
|
||||
public event Func<Task>? OnListUsers;
|
||||
@@ -51,6 +52,7 @@ public class CommandHandler
|
||||
"avatar" => await HandleAvatar(args),
|
||||
"servers" => await HandleServers(),
|
||||
"join" => await HandleJoin(args),
|
||||
"passwd" => await HandlePasswd(args),
|
||||
"leave" => await HandleLeave(),
|
||||
"topic" => await HandleTopic(args),
|
||||
"users" => await HandleUsers(),
|
||||
@@ -204,6 +206,20 @@ public class CommandHandler
|
||||
return new CommandResult(true);
|
||||
}
|
||||
|
||||
private async Task<CommandResult> HandlePasswd(string args)
|
||||
{
|
||||
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length != 2)
|
||||
return new CommandResult(true, "Usage: /passwd <old passphrase> <new passphrase> — changes the current encrypted channel's passphrase", IsError: true);
|
||||
|
||||
if (parts[1].Length < 3)
|
||||
return new CommandResult(true, "New passphrase must be at least 3 characters.", IsError: true);
|
||||
|
||||
if (OnChangeRoomPassword is not null)
|
||||
await OnChangeRoomPassword(parts[0], parts[1]);
|
||||
return new CommandResult(true);
|
||||
}
|
||||
|
||||
private async Task<CommandResult> HandleLeave()
|
||||
{
|
||||
if (OnLeaveChannel is not null)
|
||||
@@ -347,6 +363,7 @@ public class CommandHandler
|
||||
/profile [username] - View a profile
|
||||
/servers - Open saved servers
|
||||
/join <channel> [password] - Join a channel (password if protected)
|
||||
/passwd <old> <new> - Change current encrypted channel's passphrase
|
||||
/leave - Leave current channel
|
||||
/topic <text> - Set channel topic
|
||||
/users - List online users
|
||||
|
||||
@@ -23,6 +23,13 @@ public class SavedServer
|
||||
public string? RefreshToken { get; set; }
|
||||
public bool RememberMe { get; set; }
|
||||
public DateTimeOffset LastConnected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached room content keys for end-to-end encrypted channels on this server,
|
||||
/// keyed by channel name (base64). Like RefreshToken, these live only on the
|
||||
/// user's machine — the server never sees them.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> ChannelKeys { get; set; } = [];
|
||||
}
|
||||
|
||||
public class AccountPreset
|
||||
|
||||
@@ -185,7 +185,8 @@ public sealed class ApiClient : IDisposable
|
||||
return result?.AvatarAscii;
|
||||
}
|
||||
|
||||
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null)
|
||||
public async Task<MessageDto?> UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null,
|
||||
string? declaredType = null, string? encryptedContent = null)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
using var content = new MultipartFormDataContent();
|
||||
@@ -193,6 +194,13 @@ public sealed class ApiClient : IDisposable
|
||||
streamContent.Headers.ContentType = new MediaTypeHeaderValue(GetContentType(fileName));
|
||||
content.Add(streamContent, "file", fileName);
|
||||
|
||||
// E2E channels: the blob is ciphertext, so the client declares the type and
|
||||
// supplies the room-encrypted message content the server can't produce.
|
||||
if (declaredType is not null)
|
||||
content.Add(new StringContent(declaredType), "type");
|
||||
if (encryptedContent is not null)
|
||||
content.Add(new StringContent(encryptedContent), "content");
|
||||
|
||||
var sizeQuery = size is not null ? $"?size={size}" : "";
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PostAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/upload{sizeQuery}", content));
|
||||
@@ -228,16 +236,40 @@ public sealed class ApiClient : IDisposable
|
||||
return tempPath;
|
||||
}
|
||||
|
||||
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true, string? password = null)
|
||||
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true,
|
||||
string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
var request = new CreateChannelRequest(name, topic, isPublic, password);
|
||||
var request = new CreateChannelRequest(name, topic, isPublic, password, encryptionSalt, wrappedRoomKey);
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PostAsJsonAsync("/api/channels", request));
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a channel's public crypto metadata (whether it's E2E-encrypted and its
|
||||
/// key-derivation salt). Returns null when the channel doesn't exist.
|
||||
/// </summary>
|
||||
public async Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
using var response = await AuthenticatedGetAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/crypto");
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return null;
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadFromJsonAsync<ChannelCryptoDto>();
|
||||
}
|
||||
|
||||
public async Task<ChannelDto?> RekeyChannelAsync(string channelName, RekeyChannelRequest request)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
using var response = await AuthenticatedRequestAsync(() =>
|
||||
_http.PostAsJsonAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/rekey", request));
|
||||
await EnsureSuccessAsync(response);
|
||||
return await response.Content.ReadFromJsonAsync<ChannelDto>();
|
||||
}
|
||||
|
||||
public async Task<ChannelDto?> UpdateChannelTopicAsync(string channelName, string? topic)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
|
||||
@@ -24,6 +24,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
private EchoHubConnection? _connection;
|
||||
private ApiClient? _apiClient;
|
||||
private readonly ClientEncryptionService _encryption = new();
|
||||
private readonly RoomKeyStore _roomKeys = new();
|
||||
private readonly HashSet<string> _joinedChannels = [];
|
||||
|
||||
// ── Properties ────────────────────────────────────────────────────────
|
||||
@@ -31,6 +32,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
public bool IsConnected => _connection?.IsConnected == true;
|
||||
public bool IsAuthenticated => _apiClient is not null;
|
||||
public ApiClient? Api => _apiClient;
|
||||
public RoomKeyStore RoomKeys => _roomKeys;
|
||||
|
||||
// ── Events (forwarded from SignalR) ───────────────────────────────────
|
||||
|
||||
@@ -101,7 +103,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
if (_connection is not null)
|
||||
await _connection.DisposeAsync();
|
||||
|
||||
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption);
|
||||
_roomKeys.LoadForServer(info.ServerUrl);
|
||||
_connection = new EchoHubConnection(info.ServerUrl, _apiClient, _encryption, _roomKeys);
|
||||
WireConnectionEvents(_connection);
|
||||
await _connection.ConnectAsync();
|
||||
|
||||
@@ -156,6 +159,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
_apiClient?.Dispose();
|
||||
_apiClient = null;
|
||||
_joinedChannels.Clear();
|
||||
_roomKeys.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -169,14 +173,14 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
|
||||
// ── Channel Operations ────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<MessageDto>> JoinChannelAsync(string channelName, string? password = null)
|
||||
public async Task<JoinOutcome> JoinChannelAsync(string channelName, string? password = null)
|
||||
{
|
||||
if (_connection is null) throw new InvalidOperationException("Not connected");
|
||||
try
|
||||
{
|
||||
var history = await _connection.JoinChannelAsync(channelName, password);
|
||||
var outcome = await _connection.JoinChannelAsync(channelName, password);
|
||||
_joinedChannels.Add(channelName);
|
||||
return history;
|
||||
return outcome;
|
||||
}
|
||||
catch (ChannelPasswordRequiredException)
|
||||
{
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Core.Security;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
namespace EchoHub.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Result of joining a channel: decrypted history plus, for end-to-end encrypted
|
||||
/// channels, the key envelope needed to unlock the room content key.
|
||||
/// </summary>
|
||||
public sealed record JoinOutcome(List<MessageDto> History, string? EncryptionSalt, string? WrappedRoomKey);
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when joining a channel fails because a password is required or incorrect.
|
||||
/// The UI catches this to prompt the user and retry.
|
||||
@@ -21,8 +28,12 @@ public sealed class ChannelPasswordRequiredException : Exception
|
||||
|
||||
public sealed class EchoHubConnection : IAsyncDisposable
|
||||
{
|
||||
public const string LockedMessagePlaceholder =
|
||||
"[encrypted — rejoin this channel with its passphrase to unlock]";
|
||||
|
||||
private readonly HubConnection _connection;
|
||||
private readonly ClientEncryptionService _encryption;
|
||||
private readonly RoomKeyStore _roomKeys;
|
||||
|
||||
public event Action<MessageDto>? OnMessageReceived;
|
||||
public event Action<string, string, UserPresenceDto?>? OnUserJoined;
|
||||
@@ -40,9 +51,10 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
|
||||
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
||||
|
||||
public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption)
|
||||
public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption, RoomKeyStore roomKeys)
|
||||
{
|
||||
_encryption = encryption;
|
||||
_roomKeys = roomKeys;
|
||||
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
|
||||
|
||||
_connection = new HubConnectionBuilder()
|
||||
@@ -79,9 +91,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
{
|
||||
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
|
||||
{
|
||||
// Decrypt message content received from server
|
||||
var decrypted = message with { Content = _encryption.Decrypt(message.Content) };
|
||||
OnMessageReceived?.Invoke(decrypted);
|
||||
OnMessageReceived?.Invoke(DecryptMessage(message));
|
||||
});
|
||||
|
||||
_connection.On<string, string, UserPresenceDto?>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username, presence) =>
|
||||
@@ -148,7 +158,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
OnConnectionStateChanged?.Invoke("Disconnected");
|
||||
}
|
||||
|
||||
public async Task<List<MessageDto>> JoinChannelAsync(string channelName, string? password = null)
|
||||
public async Task<JoinOutcome> JoinChannelAsync(string channelName, string? password = null)
|
||||
{
|
||||
var result = await _connection.InvokeAsync<JoinChannelResult>("JoinChannel", channelName, password);
|
||||
if (!result.Success)
|
||||
@@ -157,7 +167,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
throw new ChannelPasswordRequiredException(channelName, result.Error ?? "Channel is password protected.");
|
||||
throw new InvalidOperationException(result.Error ?? "Failed to join channel.");
|
||||
}
|
||||
return DecryptMessages(result.History);
|
||||
return new JoinOutcome(DecryptMessages(result.History), result.EncryptionSalt, result.WrappedRoomKey);
|
||||
}
|
||||
|
||||
public async Task LeaveChannelAsync(string channelName)
|
||||
@@ -167,7 +177,10 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
|
||||
public async Task SendMessageAsync(string channelName, string content)
|
||||
{
|
||||
// Encrypt content before sending to server
|
||||
// Room layer first (end-to-end, server can't read), then transport encryption
|
||||
if (_roomKeys.TryGetKey(channelName, out var roomKey))
|
||||
content = RoomCrypto.EncryptText(content, roomKey);
|
||||
|
||||
var encrypted = _encryption.Encrypt(content);
|
||||
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
|
||||
}
|
||||
@@ -190,7 +203,32 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
|
||||
private List<MessageDto> DecryptMessages(List<MessageDto> messages)
|
||||
{
|
||||
return messages.Select(m => m with { Content = _encryption.Decrypt(m.Content) }).ToList();
|
||||
return messages.Select(DecryptMessage).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips the transport encryption, then the room layer for E2E channels.
|
||||
/// Without the room key the content is replaced by a locked placeholder —
|
||||
/// re-fetch history after unlocking to render it.
|
||||
/// </summary>
|
||||
private MessageDto DecryptMessage(MessageDto message)
|
||||
{
|
||||
var content = _encryption.Decrypt(message.Content);
|
||||
|
||||
if (RoomCrypto.IsRoomCiphertext(content))
|
||||
{
|
||||
if (_roomKeys.TryGetKey(message.ChannelName, out var roomKey)
|
||||
&& RoomCrypto.TryDecryptText(content, roomKey, out var plaintext))
|
||||
{
|
||||
content = plaintext;
|
||||
}
|
||||
else
|
||||
{
|
||||
content = LockedMessagePlaceholder;
|
||||
}
|
||||
}
|
||||
|
||||
return message with { Content = content };
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using EchoHub.Client.Config;
|
||||
using Serilog;
|
||||
|
||||
namespace EchoHub.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Holds room content keys for end-to-end encrypted channels: in-memory for the
|
||||
/// active session, persisted per-server in the client config (like saved sessions)
|
||||
/// so users don't retype the passphrase every launch. Keys never leave this machine.
|
||||
/// </summary>
|
||||
public sealed class RoomKeyStore
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _keys = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Lock _lock = new();
|
||||
private string? _serverUrl;
|
||||
|
||||
/// <summary>Binds the store to a server and loads that server's cached keys from config.</summary>
|
||||
public void LoadForServer(string serverUrl)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_serverUrl = serverUrl;
|
||||
_keys.Clear();
|
||||
|
||||
var server = FindServer(ConfigManager.Load(), serverUrl);
|
||||
if (server is null) return;
|
||||
|
||||
foreach (var (channel, base64) in server.ChannelKeys)
|
||||
{
|
||||
try
|
||||
{
|
||||
_keys[channel] = Convert.FromBase64String(base64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
Log.Warning("Ignoring malformed cached room key for #{Channel}", channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetKey(string channelName, out byte[] key)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_keys.TryGetValue(channelName, out var k))
|
||||
{
|
||||
key = k;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
key = [];
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool HasKey(string channelName) => TryGetKey(channelName, out _);
|
||||
|
||||
/// <summary>Stores a key for the session and persists it to the server's config entry.</summary>
|
||||
public void StoreKey(string channelName, byte[] key)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_keys[channelName] = key;
|
||||
Persist(server => server.ChannelKeys[channelName] = Convert.ToBase64String(key));
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveKey(string channelName)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_keys.Remove(channelName);
|
||||
Persist(server => server.ChannelKeys.Remove(channelName));
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_keys.Clear();
|
||||
_serverUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Persist(Action<SavedServer> mutate)
|
||||
{
|
||||
if (_serverUrl is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var config = ConfigManager.Load();
|
||||
var server = FindServer(config, _serverUrl);
|
||||
if (server is null) return; // server not saved yet — key stays in-memory only
|
||||
|
||||
mutate(server);
|
||||
ConfigManager.Save(config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to persist room key cache");
|
||||
}
|
||||
}
|
||||
|
||||
private static SavedServer? FindServer(ClientConfig config, string url) =>
|
||||
config.SavedServers.FirstOrDefault(s =>
|
||||
string.Equals(s.Url, url, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -255,6 +255,22 @@ public sealed class ChatMessageManager
|
||||
lines.Add(new ChatLine($" {trimmed}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Clickable action to download the original image below the ASCII art
|
||||
if (message.AttachmentUrl is not null)
|
||||
{
|
||||
var imageName = message.AttachmentFileName ?? "image";
|
||||
var imageSize = FormatFileSize(message.AttachmentFileSize);
|
||||
var saveLine = new ChatLine(new List<ChatSegment>
|
||||
{
|
||||
new(" ", null),
|
||||
new($"[↓ save original] {imageName} [{imageSize}]", ChatColors.FileAttr),
|
||||
});
|
||||
saveLine.AttachmentUrl = message.AttachmentUrl;
|
||||
saveLine.AttachmentFileName = imageName;
|
||||
saveLine.Type = MessageType.Image;
|
||||
lines.Add(saveLine);
|
||||
}
|
||||
break;
|
||||
|
||||
case MessageType.Audio:
|
||||
|
||||
@@ -60,7 +60,7 @@ public sealed partial class MainWindow : Runnable
|
||||
private static readonly string[] SlashCommands =
|
||||
[
|
||||
"/status", "/nick", "/color", "/theme", "/send",
|
||||
"/avatar", "/profile", "/servers", "/join", "/leave",
|
||||
"/avatar", "/profile", "/servers", "/join", "/passwd", "/leave",
|
||||
"/topic", "/users", "/kick", "/ban", "/unban",
|
||||
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
|
||||
];
|
||||
@@ -154,6 +154,11 @@ public sealed partial class MainWindow : Runnable
|
||||
/// </summary>
|
||||
public event Action<string, string>? OnFileDownloadRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user activates an image's "[save original]" line. Parameters: attachmentUrl, fileName.
|
||||
/// </summary>
|
||||
public event Action<string, string>? OnImageSaveRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user activates a username (in userlist or message). Parameter is the username.
|
||||
/// </summary>
|
||||
@@ -466,6 +471,13 @@ public sealed partial class MainWindow : Runnable
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.Type == MessageType.Image)
|
||||
{
|
||||
OnImageSaveRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var lineText = line.ToString();
|
||||
|
||||
@@ -6,15 +6,20 @@ public interface IChannelService
|
||||
{
|
||||
// Channel CRUD
|
||||
Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit);
|
||||
Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null);
|
||||
Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic,
|
||||
string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null);
|
||||
Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic);
|
||||
Task<ChannelOperationResult> SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password);
|
||||
Task<ChannelOperationResult> RekeyChannelAsync(Guid callerUserId, string channelName,
|
||||
string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey);
|
||||
Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName);
|
||||
|
||||
// Channel queries
|
||||
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
|
||||
Task<List<ChannelListItem>> GetChannelListAsync();
|
||||
Task<ChannelDto?> GetChannelByNameAsync(string channelName);
|
||||
Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName);
|
||||
Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName);
|
||||
|
||||
// Membership
|
||||
Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null);
|
||||
|
||||
@@ -22,7 +22,8 @@ public record ChannelDto(
|
||||
bool IsPublic,
|
||||
int MessageCount,
|
||||
DateTimeOffset CreatedAt,
|
||||
bool IsProtected = false);
|
||||
bool IsProtected = false,
|
||||
bool IsEncrypted = false);
|
||||
|
||||
public record UserDto(
|
||||
Guid Id,
|
||||
@@ -34,13 +35,41 @@ public record UserDto(
|
||||
|
||||
public record SendMessageRequest(string ChannelName, string Content);
|
||||
|
||||
public record CreateChannelRequest(string Name, string? Topic = null, bool IsPublic = true, string? Password = null);
|
||||
public record CreateChannelRequest(
|
||||
string Name,
|
||||
string? Topic = null,
|
||||
bool IsPublic = true,
|
||||
string? Password = null,
|
||||
string? EncryptionSalt = null,
|
||||
string? WrappedRoomKey = null);
|
||||
|
||||
/// <summary>
|
||||
/// Public crypto metadata for a channel — enough for a client to derive its join
|
||||
/// credential from a passphrase. Never includes the wrapped room key.
|
||||
/// </summary>
|
||||
public record ChannelCryptoDto(bool IsEncrypted, string? EncryptionSalt);
|
||||
|
||||
/// <summary>
|
||||
/// Passphrase change for an encrypted channel: the client proves knowledge of the old
|
||||
/// passphrase (old auth key), then supplies the re-wrapped room key under the new one.
|
||||
/// </summary>
|
||||
public record RekeyChannelRequest(
|
||||
string OldPassword,
|
||||
string NewPassword,
|
||||
string NewEncryptionSalt,
|
||||
string NewWrappedRoomKey);
|
||||
|
||||
public record UpdateTopicRequest(string? Topic);
|
||||
|
||||
public record SendUrlRequest(string Url);
|
||||
|
||||
public record JoinChannelResult(bool Success, List<MessageDto> History, string? Error = null, bool PasswordRequired = false);
|
||||
public record JoinChannelResult(
|
||||
bool Success,
|
||||
List<MessageDto> History,
|
||||
string? Error = null,
|
||||
bool PasswordRequired = false,
|
||||
string? EncryptionSalt = null,
|
||||
string? WrappedRoomKey = null);
|
||||
|
||||
public record EmbedDto(
|
||||
string? SiteName,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -6,4 +6,8 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -7,6 +7,12 @@ public class Channel
|
||||
public string? Topic { get; set; }
|
||||
public bool IsPublic { get; set; } = true;
|
||||
public string? PasswordHash { get; set; }
|
||||
|
||||
// End-to-end encryption envelope (client-generated; server cannot decrypt room content).
|
||||
// EncryptionSalt: PBKDF2 salt for passphrase-derived keys. WrappedRoomKey: the room
|
||||
// content key encrypted under the passphrase-derived key-encryption key.
|
||||
public string? EncryptionSalt { get; set; }
|
||||
public string? WrappedRoomKey { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public Guid CreatedByUserId { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace EchoHub.Core.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Client-side envelope encryption for private (end-to-end encrypted) channels.
|
||||
///
|
||||
/// Design: at creation the client generates a random 256-bit room content key (RCK)
|
||||
/// that encrypts all room content. The RCK is stored on the server *wrapped*
|
||||
/// (AES-GCM encrypted) by a key derived from the passphrase, next to a BCrypt hash
|
||||
/// of a separately derived auth key used as the join gate. The passphrase, the
|
||||
/// key-encryption key, and the RCK never leave the client, so the server can gate
|
||||
/// joins and count/measure content without being able to read it. Changing the
|
||||
/// passphrase only re-wraps the RCK — history is never re-encrypted.
|
||||
///
|
||||
/// Derivation: PBKDF2-SHA256(passphrase, salt, 210000 iterations) → 64 bytes;
|
||||
/// first 32 bytes are the auth key (sent to the server as lowercase hex),
|
||||
/// last 32 bytes are the key-encryption key (never sent).
|
||||
/// </summary>
|
||||
public static class RoomCrypto
|
||||
{
|
||||
public const string CiphertextPrefix = "$RC1$";
|
||||
|
||||
private const int Pbkdf2Iterations = 210_000;
|
||||
private const int SaltSizeBytes = 16;
|
||||
private const int KeySizeBytes = 32;
|
||||
private const int NonceSizeBytes = 12;
|
||||
private const int TagSizeBytes = 16;
|
||||
|
||||
public sealed record DerivedKeys(string AuthKeyHex, byte[] KeyEncryptionKey);
|
||||
|
||||
public static byte[] GenerateSalt() => RandomNumberGenerator.GetBytes(SaltSizeBytes);
|
||||
|
||||
public static byte[] GenerateRoomKey() => RandomNumberGenerator.GetBytes(KeySizeBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Derives the auth key (join gate credential) and key-encryption key from a passphrase.
|
||||
/// </summary>
|
||||
public static DerivedKeys DeriveKeys(string passphrase, byte[] salt)
|
||||
{
|
||||
var okm = Rfc2898DeriveBytes.Pbkdf2(
|
||||
Encoding.UTF8.GetBytes(passphrase), salt, Pbkdf2Iterations,
|
||||
HashAlgorithmName.SHA256, KeySizeBytes * 2);
|
||||
|
||||
var authKey = Convert.ToHexString(okm.AsSpan(0, KeySizeBytes)).ToLowerInvariant();
|
||||
var kek = okm[KeySizeBytes..];
|
||||
CryptographicOperations.ZeroMemory(okm.AsSpan(0, KeySizeBytes));
|
||||
return new DerivedKeys(authKey, kek);
|
||||
}
|
||||
|
||||
/// <summary>Encrypts UTF-8 text with the room key. Output: $RC1$base64(nonce||tag||ciphertext).</summary>
|
||||
public static string EncryptText(string plaintext, byte[] key) =>
|
||||
CiphertextPrefix + Convert.ToBase64String(EncryptBytes(Encoding.UTF8.GetBytes(plaintext), key));
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts text produced by <see cref="EncryptText"/>. Returns false when the input
|
||||
/// is not room ciphertext or the key does not match.
|
||||
/// </summary>
|
||||
public static bool TryDecryptText(string content, byte[] key, out string plaintext)
|
||||
{
|
||||
plaintext = string.Empty;
|
||||
if (!IsRoomCiphertext(content))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var blob = Convert.FromBase64String(content[CiphertextPrefix.Length..]);
|
||||
plaintext = Encoding.UTF8.GetString(DecryptBytes(blob, key));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsRoomCiphertext(string? content) =>
|
||||
content is not null && content.StartsWith(CiphertextPrefix, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>Encrypts a binary blob (file contents) with the room key: nonce||tag||ciphertext.</summary>
|
||||
public static byte[] EncryptBytes(byte[] plaintext, byte[] key)
|
||||
{
|
||||
var nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes);
|
||||
var ciphertext = new byte[plaintext.Length];
|
||||
var tag = new byte[TagSizeBytes];
|
||||
|
||||
using var aes = new AesGcm(key, TagSizeBytes);
|
||||
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||
|
||||
var blob = new byte[NonceSizeBytes + TagSizeBytes + ciphertext.Length];
|
||||
nonce.CopyTo(blob, 0);
|
||||
tag.CopyTo(blob, NonceSizeBytes);
|
||||
ciphertext.CopyTo(blob, NonceSizeBytes + TagSizeBytes);
|
||||
return blob;
|
||||
}
|
||||
|
||||
/// <summary>Decrypts a blob produced by <see cref="EncryptBytes"/>. Throws <see cref="CryptographicException"/> on key mismatch.</summary>
|
||||
public static byte[] DecryptBytes(byte[] blob, byte[] key)
|
||||
{
|
||||
if (blob.Length < NonceSizeBytes + TagSizeBytes)
|
||||
throw new CryptographicException("Ciphertext blob is too short.");
|
||||
|
||||
var nonce = blob.AsSpan(0, NonceSizeBytes);
|
||||
var tag = blob.AsSpan(NonceSizeBytes, TagSizeBytes);
|
||||
var ciphertext = blob.AsSpan(NonceSizeBytes + TagSizeBytes);
|
||||
var plaintext = new byte[ciphertext.Length];
|
||||
|
||||
using var aes = new AesGcm(key, TagSizeBytes);
|
||||
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
/// <summary>Wraps the room content key under the key-encryption key for server storage.</summary>
|
||||
public static string WrapRoomKey(byte[] roomKey, byte[] kek) =>
|
||||
Convert.ToBase64String(EncryptBytes(roomKey, kek));
|
||||
|
||||
/// <summary>Unwraps the stored room content key. Returns false when the KEK (passphrase) is wrong.</summary>
|
||||
public static bool TryUnwrapRoomKey(string wrappedRoomKey, byte[] kek, out byte[] roomKey)
|
||||
{
|
||||
roomKey = [];
|
||||
try
|
||||
{
|
||||
roomKey = DecryptBytes(Convert.FromBase64String(wrappedRoomKey), kek);
|
||||
return roomKey.Length == KeySizeBytes;
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace EchoHub.Server.Services;
|
||||
namespace EchoHub.Core.Services;
|
||||
|
||||
public static class FileValidationHelper
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace EchoHub.Server.Services;
|
||||
namespace EchoHub.Core.Services;
|
||||
|
||||
public class ImageToAsciiService
|
||||
{
|
||||
|
||||
@@ -390,6 +390,16 @@ public sealed class IrcCommandHandler
|
||||
continue;
|
||||
}
|
||||
|
||||
// End-to-end encrypted channels can't be read over IRC (the gateway would
|
||||
// have to hold the room key server-side, defeating the privacy guarantee).
|
||||
var crypto = await _channelService.GetChannelCryptoAsync(channelName);
|
||||
if (crypto?.IsEncrypted == true)
|
||||
{
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_BADCHANNELKEY,
|
||||
$"#{channelName} :Cannot join channel — end-to-end encrypted, use the EchoHub client");
|
||||
continue;
|
||||
}
|
||||
|
||||
var (history, error, passwordRequired) = await _chatService.JoinChannelAsync(
|
||||
_conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName, key);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.Contracts;
|
||||
using EchoHub.Core.Services;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Server.Data;
|
||||
@@ -65,7 +66,8 @@ public class ChannelsController : ControllerBase
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var result = await _channelService.CreateChannelAsync(
|
||||
Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic, request.Password);
|
||||
Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic, request.Password,
|
||||
request.EncryptionSalt, request.WrappedRoomKey);
|
||||
if (!result.IsSuccess)
|
||||
return MapChannelError(result);
|
||||
|
||||
@@ -75,6 +77,43 @@ public class ChannelsController : ControllerBase
|
||||
return Created($"/api/channels/{result.Channel.Name}", result.Channel);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public crypto metadata for a channel: whether it is end-to-end encrypted and the
|
||||
/// PBKDF2 salt clients need to derive their join credential. Never returns the
|
||||
/// wrapped room key — that is only handed out after a successful join.
|
||||
/// </summary>
|
||||
[HttpGet("{channel}/crypto")]
|
||||
public async Task<IActionResult> GetChannelCrypto(string channel)
|
||||
{
|
||||
var crypto = await _channelService.GetChannelCryptoAsync(channel);
|
||||
if (crypto is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channel}' does not exist."));
|
||||
|
||||
return Ok(crypto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes an encrypted channel's passphrase by re-wrapping its room key.
|
||||
/// The caller proves knowledge of the old passphrase via the old auth key;
|
||||
/// history is never re-encrypted (the room content key does not change).
|
||||
/// </summary>
|
||||
[HttpPost("{channel}/rekey")]
|
||||
public async Task<IActionResult> RekeyChannel(string channel, [FromBody] RekeyChannelRequest request)
|
||||
{
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var result = await _channelService.RekeyChannelAsync(
|
||||
Guid.Parse(userIdClaim), channel,
|
||||
request.OldPassword, request.NewPassword,
|
||||
request.NewEncryptionSalt, request.NewWrappedRoomKey);
|
||||
if (!result.IsSuccess)
|
||||
return MapChannelError(result);
|
||||
|
||||
return Ok(result.Channel);
|
||||
}
|
||||
|
||||
[HttpPut("{channel}/topic")]
|
||||
public async Task<IActionResult> UpdateTopic(string channel, [FromBody] UpdateTopicRequest request)
|
||||
{
|
||||
@@ -131,34 +170,68 @@ public class ChannelsController : ControllerBase
|
||||
|
||||
var file = Request.Form.Files[0];
|
||||
|
||||
// Detect file type early so we can apply the correct size limit
|
||||
using var stream = file.OpenReadStream();
|
||||
var isImage = FileValidationHelper.IsValidImage(stream);
|
||||
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
|
||||
|
||||
var maxSize = isImage ? HubConstants.MaxImageSizeBytes
|
||||
: isAudio ? HubConstants.MaxAudioFileSizeBytes
|
||||
: HubConstants.MaxFileSizeBytes;
|
||||
|
||||
if (file.Length > maxSize)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB."));
|
||||
|
||||
var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
|
||||
|
||||
var messageType = isImage ? MessageType.Image
|
||||
: isAudio ? MessageType.Audio
|
||||
: MessageType.File;
|
||||
MessageType messageType;
|
||||
string content;
|
||||
string fileId;
|
||||
|
||||
if (isImage)
|
||||
if (channelDto.IsEncrypted)
|
||||
{
|
||||
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
||||
using var imageStream = System.IO.File.OpenRead(filePath);
|
||||
content = _asciiService.ConvertToAscii(imageStream, w, h);
|
||||
// E2E-encrypted channel: the blob is ciphertext the server cannot inspect.
|
||||
// The client declares the type and supplies pre-rendered, room-encrypted
|
||||
// content (ASCII art for images, encrypted filename otherwise).
|
||||
messageType = Request.Form["type"].ToString().ToLowerInvariant() switch
|
||||
{
|
||||
"image" => MessageType.Image,
|
||||
"audio" => MessageType.Audio,
|
||||
_ => MessageType.File,
|
||||
};
|
||||
|
||||
var declaredMax = messageType switch
|
||||
{
|
||||
MessageType.Image => HubConstants.MaxImageSizeBytes,
|
||||
MessageType.Audio => HubConstants.MaxAudioFileSizeBytes,
|
||||
_ => HubConstants.MaxFileSizeBytes,
|
||||
};
|
||||
if (file.Length > declaredMax)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {declaredMax / (1024 * 1024)} MB."));
|
||||
|
||||
var clientContent = Request.Form["content"].ToString();
|
||||
content = string.IsNullOrEmpty(clientContent) ? file.FileName : clientContent;
|
||||
|
||||
using var encryptedStream = file.OpenReadStream();
|
||||
(fileId, _) = await _fileStorage.SaveFileAsync(encryptedStream, file.FileName);
|
||||
}
|
||||
else
|
||||
{
|
||||
content = file.FileName;
|
||||
// Detect file type early so we can apply the correct size limit
|
||||
using var stream = file.OpenReadStream();
|
||||
var isImage = FileValidationHelper.IsValidImage(stream);
|
||||
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
|
||||
|
||||
var maxSize = isImage ? HubConstants.MaxImageSizeBytes
|
||||
: isAudio ? HubConstants.MaxAudioFileSizeBytes
|
||||
: HubConstants.MaxFileSizeBytes;
|
||||
|
||||
if (file.Length > maxSize)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB."));
|
||||
|
||||
string filePath;
|
||||
(fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
|
||||
|
||||
messageType = isImage ? MessageType.Image
|
||||
: isAudio ? MessageType.Audio
|
||||
: MessageType.File;
|
||||
|
||||
if (isImage)
|
||||
{
|
||||
var (w, h) = ImageToAsciiService.GetDimensions(size);
|
||||
using var imageStream = System.IO.File.OpenRead(filePath);
|
||||
content = _asciiService.ConvertToAscii(imageStream, w, h);
|
||||
}
|
||||
else
|
||||
{
|
||||
content = file.FileName;
|
||||
}
|
||||
}
|
||||
|
||||
var attachmentUrl = $"/api/files/{fileId}";
|
||||
@@ -219,6 +292,10 @@ public class ChannelsController : ControllerBase
|
||||
if (channelDto is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
if (channelDto.IsEncrypted)
|
||||
return BadRequest(new ErrorResponse(
|
||||
"Sending images by URL is not available in end-to-end encrypted channels — download the image and /send the file instead."));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Url))
|
||||
return BadRequest(new ErrorResponse("URL is required."));
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.Contracts;
|
||||
using EchoHub.Core.Services;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Server.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
@@ -45,6 +45,8 @@ public class EchoHubDbContext : DbContext
|
||||
entity.Property(c => c.Name).IsRequired().HasMaxLength(100);
|
||||
entity.Property(c => c.Topic).HasMaxLength(500);
|
||||
entity.Property(c => c.PasswordHash).HasMaxLength(100);
|
||||
entity.Property(c => c.EncryptionSalt).HasMaxLength(64);
|
||||
entity.Property(c => c.WrappedRoomKey).HasMaxLength(200);
|
||||
|
||||
entity.HasMany(c => c.Messages)
|
||||
.WithOne(m => m.Channel)
|
||||
|
||||
Generated
+279
@@ -0,0 +1,279 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using EchoHub.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EchoHub.Server.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(EchoHubDbContext))]
|
||||
[Migration("20260716012917_AddChannelEncryptionEnvelope")]
|
||||
partial class AddChannelEncryptionEnvelope
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("CreatedByUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EncryptionSalt")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WrappedRoomKey")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Channels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("JoinedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("UserId", "ChannelId");
|
||||
|
||||
b.HasIndex("ChannelId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("ChannelMemberships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AttachmentFileName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("AttachmentFileSize")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AttachmentUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EmbedJson")
|
||||
.HasMaxLength(32000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SenderUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SenderUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SentAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChannelId");
|
||||
|
||||
b.HasIndex("SentAt");
|
||||
|
||||
b.ToTable("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ExpiresAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("RevokedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AvatarAscii")
|
||||
.HasMaxLength(10000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsMuted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("LastSeenAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("MutedUntil")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("NicknameColor")
|
||||
.HasMaxLength(7)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StatusMessage")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||
{
|
||||
b.HasOne("EchoHub.Core.Models.Channel", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("EchoHub.Core.Models.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||
{
|
||||
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||
.WithMany("Messages")
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Channel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("EchoHub.Core.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||
{
|
||||
b.Navigation("Messages");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EchoHub.Server.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddChannelEncryptionEnvelope : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "EncryptionSalt",
|
||||
table: "Channels",
|
||||
type: "TEXT",
|
||||
maxLength: 64,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "WrappedRoomKey",
|
||||
table: "Channels",
|
||||
type: "TEXT",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EncryptionSalt",
|
||||
table: "Channels");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WrappedRoomKey",
|
||||
table: "Channels");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ namespace EchoHub.Server.Data.Migrations
|
||||
b.Property<Guid>("CreatedByUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EncryptionSalt")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsPublic")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -45,6 +49,10 @@ namespace EchoHub.Server.Data.Migrations
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WrappedRoomKey")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
|
||||
@@ -14,9 +14,12 @@ public class ChatHub : Hub<IEchoHubClient>
|
||||
private readonly IChatService _chatService;
|
||||
private readonly ILogger<ChatHub> _logger;
|
||||
|
||||
public ChatHub(IChatService chatService, ILogger<ChatHub> logger)
|
||||
private readonly IChannelService _channelService;
|
||||
|
||||
public ChatHub(IChatService chatService, IChannelService channelService, ILogger<ChatHub> logger)
|
||||
{
|
||||
_chatService = chatService;
|
||||
_channelService = channelService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -67,7 +70,12 @@ public class ChatHub : Hub<IEchoHubClient>
|
||||
return new JoinChannelResult(false, [], error, passwordRequired);
|
||||
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, channelName.ToLowerInvariant().Trim());
|
||||
return new JoinChannelResult(true, history);
|
||||
|
||||
// Members of encrypted channels receive the key envelope so they can unwrap
|
||||
// the room content key with their passphrase (the server can't).
|
||||
var (encryptionSalt, wrappedRoomKey) = await _channelService.GetChannelKeyEnvelopeAsync(channelName);
|
||||
return new JoinChannelResult(true, history,
|
||||
EncryptionSalt: encryptionSalt, WrappedRoomKey: wrappedRoomKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text;
|
||||
using System.Threading.RateLimiting;
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.Contracts;
|
||||
using EchoHub.Core.Services;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Server.Auth;
|
||||
using EchoHub.Server.Data;
|
||||
|
||||
@@ -41,14 +41,16 @@ public class ChannelService : IChannelService
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(c => new ChannelDto(
|
||||
c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt, c.PasswordHash != null))
|
||||
c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt,
|
||||
c.PasswordHash != null, c.WrappedRoomKey != null))
|
||||
.ToListAsync();
|
||||
|
||||
return new PaginatedResponse<ChannelDto>(channels, total, offset, limit);
|
||||
}
|
||||
|
||||
public async Task<ChannelOperationResult> CreateChannelAsync(
|
||||
Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null)
|
||||
Guid creatorUserId, string name, string? topic, bool isPublic,
|
||||
string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Channel name is required.");
|
||||
@@ -63,6 +65,12 @@ public class ChannelService : IChannelService
|
||||
if (passwordError is not null)
|
||||
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError);
|
||||
|
||||
// The E2E envelope (client-generated) only makes sense on password-gated channels
|
||||
var hasEnvelope = !string.IsNullOrWhiteSpace(encryptionSalt) && !string.IsNullOrWhiteSpace(wrappedRoomKey);
|
||||
if (hasEnvelope && password is null)
|
||||
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
|
||||
"Encrypted channels require a password.");
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||
|
||||
@@ -77,6 +85,8 @@ public class ChannelService : IChannelService
|
||||
IsPublic = isPublic,
|
||||
CreatedByUserId = creatorUserId,
|
||||
PasswordHash = password is not null ? BCrypt.Net.BCrypt.HashPassword(password) : null,
|
||||
EncryptionSalt = hasEnvelope ? encryptionSalt : null,
|
||||
WrappedRoomKey = hasEnvelope ? wrappedRoomKey : null,
|
||||
};
|
||||
|
||||
db.Channels.Add(channel);
|
||||
@@ -91,7 +101,7 @@ public class ChannelService : IChannelService
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt,
|
||||
channel.PasswordHash != null);
|
||||
channel.PasswordHash != null, channel.WrappedRoomKey != null);
|
||||
return ChannelOperationResult.Success(dto);
|
||||
}
|
||||
|
||||
@@ -119,12 +129,14 @@ public class ChannelService : IChannelService
|
||||
|
||||
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
|
||||
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt,
|
||||
dbChannel.PasswordHash != null);
|
||||
dbChannel.PasswordHash != null, dbChannel.WrappedRoomKey != null);
|
||||
return ChannelOperationResult.Success(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets, changes, or clears (null) a channel's join password. Creator or admin only.
|
||||
/// Not available on end-to-end encrypted channels — those change passphrase via
|
||||
/// <see cref="RekeyChannelAsync"/> so the room key envelope stays consistent.
|
||||
/// </summary>
|
||||
public async Task<ChannelOperationResult> SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password)
|
||||
{
|
||||
@@ -141,6 +153,10 @@ public class ChannelService : IChannelService
|
||||
if (dbChannel is null)
|
||||
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
|
||||
|
||||
if (dbChannel.WrappedRoomKey is not null)
|
||||
return ChannelOperationResult.Fail(ChannelError.Protected,
|
||||
"This channel is end-to-end encrypted — change its passphrase from the EchoHub client (/passwd).");
|
||||
|
||||
var caller = await db.Users.FindAsync(callerUserId);
|
||||
if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin))
|
||||
return ChannelOperationResult.Fail(ChannelError.Forbidden,
|
||||
@@ -151,7 +167,55 @@ public class ChannelService : IChannelService
|
||||
|
||||
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
|
||||
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt,
|
||||
dbChannel.PasswordHash != null);
|
||||
dbChannel.PasswordHash != null, dbChannel.WrappedRoomKey != null);
|
||||
return ChannelOperationResult.Success(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Changes an encrypted channel's passphrase by swapping the join-gate hash and the
|
||||
/// wrapped room key. The room content key itself never changes, so history stays
|
||||
/// readable — the client re-wraps it under the new passphrase-derived key.
|
||||
/// Creator only: admins cannot rekey a room whose passphrase they don't know.
|
||||
/// </summary>
|
||||
public async Task<ChannelOperationResult> RekeyChannelAsync(Guid callerUserId, string channelName,
|
||||
string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey)
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
string? validatedNew = newPassword;
|
||||
var passwordError = ValidateChannelPassword(ref validatedNew);
|
||||
if (passwordError is not null)
|
||||
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError);
|
||||
if (validatedNew is null || string.IsNullOrWhiteSpace(newEncryptionSalt) || string.IsNullOrWhiteSpace(newWrappedRoomKey))
|
||||
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
|
||||
"New password, salt, and wrapped room key are required.");
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||
|
||||
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
if (dbChannel is null)
|
||||
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
|
||||
|
||||
if (dbChannel.WrappedRoomKey is null || dbChannel.PasswordHash is null)
|
||||
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
|
||||
"This channel is not end-to-end encrypted.");
|
||||
|
||||
if (dbChannel.CreatedByUserId != callerUserId)
|
||||
return ChannelOperationResult.Fail(ChannelError.Forbidden,
|
||||
"Only the channel creator can change the passphrase.");
|
||||
|
||||
if (!BCrypt.Net.BCrypt.Verify(oldPassword, dbChannel.PasswordHash))
|
||||
return ChannelOperationResult.Fail(ChannelError.Forbidden, "The current passphrase is incorrect.");
|
||||
|
||||
dbChannel.PasswordHash = BCrypt.Net.BCrypt.HashPassword(validatedNew);
|
||||
dbChannel.EncryptionSalt = newEncryptionSalt;
|
||||
dbChannel.WrappedRoomKey = newWrappedRoomKey;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
|
||||
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt,
|
||||
true, true);
|
||||
return ChannelOperationResult.Success(dto);
|
||||
}
|
||||
|
||||
@@ -179,7 +243,7 @@ public class ChannelService : IChannelService
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, 0, dbChannel.CreatedAt,
|
||||
dbChannel.PasswordHash != null);
|
||||
dbChannel.PasswordHash != null, dbChannel.WrappedRoomKey != null);
|
||||
return ChannelOperationResult.Success(dto);
|
||||
}
|
||||
|
||||
@@ -220,7 +284,32 @@ public class ChannelService : IChannelService
|
||||
if (c is null) return null;
|
||||
|
||||
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id);
|
||||
return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt, c.PasswordHash != null);
|
||||
return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt,
|
||||
c.PasswordHash != null, c.WrappedRoomKey != null);
|
||||
}
|
||||
|
||||
public async Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName)
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||
|
||||
var c = await db.Channels.FirstOrDefaultAsync(ch => ch.Name == channelName);
|
||||
if (c is null) return null;
|
||||
|
||||
return new ChannelCryptoDto(c.WrappedRoomKey != null, c.EncryptionSalt);
|
||||
}
|
||||
|
||||
public async Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName)
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||
|
||||
var c = await db.Channels.FirstOrDefaultAsync(ch => ch.Name == channelName);
|
||||
return (c?.EncryptionSalt, c?.WrappedRoomKey);
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using EchoHub.Server.Services;
|
||||
using EchoHub.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace EchoHub.Tests;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.Services;
|
||||
using EchoHub.Server.Services;
|
||||
using Xunit;
|
||||
|
||||
|
||||
@@ -287,6 +287,17 @@ public class IrcCommandHandlerTests
|
||||
Assert.Contains(lines, l => l.Contains("475") && l.Contains("#secret") && l.Contains("+k"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Join_EncryptedChannel_IsBlockedOverIrc()
|
||||
{
|
||||
_channelService.CryptoToReturn = new ChannelCryptoDto(true, "c2FsdA==");
|
||||
|
||||
var lines = await RunAuthenticated(["JOIN #vault"]);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("475") && l.Contains("#vault") && l.Contains("end-to-end encrypted"));
|
||||
Assert.Empty(_chatService.JoinedChannels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Join_SendsTopic()
|
||||
{
|
||||
|
||||
@@ -234,9 +234,24 @@ internal sealed class FakeChannelService : IChannelService
|
||||
public Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit) =>
|
||||
Task.FromResult(new PaginatedResponse<ChannelDto>([], 0, offset, limit));
|
||||
|
||||
public Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null) =>
|
||||
public ChannelCryptoDto? CryptoToReturn { get; set; }
|
||||
public ChannelOperationResult? RekeyResult { get; set; }
|
||||
public (string? EncryptionSalt, string? WrappedRoomKey) KeyEnvelopeToReturn { get; set; }
|
||||
|
||||
public Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic,
|
||||
string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null) =>
|
||||
Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
|
||||
|
||||
public Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName) =>
|
||||
Task.FromResult(CryptoToReturn);
|
||||
|
||||
public Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName) =>
|
||||
Task.FromResult(KeyEnvelopeToReturn);
|
||||
|
||||
public Task<ChannelOperationResult> RekeyChannelAsync(Guid callerUserId, string channelName,
|
||||
string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey) =>
|
||||
Task.FromResult(RekeyResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
|
||||
|
||||
public Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) =>
|
||||
Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using EchoHub.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace EchoHub.Tests;
|
||||
|
||||
public class RoomCryptoTests
|
||||
{
|
||||
[Fact]
|
||||
public void EncryptText_RoundTrips()
|
||||
{
|
||||
var key = RoomCrypto.GenerateRoomKey();
|
||||
var ciphertext = RoomCrypto.EncryptText("hello secret room", key);
|
||||
|
||||
Assert.StartsWith("$RC1$", ciphertext);
|
||||
Assert.True(RoomCrypto.TryDecryptText(ciphertext, key, out var plaintext));
|
||||
Assert.Equal("hello secret room", plaintext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryDecryptText_WrongKey_ReturnsFalse()
|
||||
{
|
||||
var ciphertext = RoomCrypto.EncryptText("hello", RoomCrypto.GenerateRoomKey());
|
||||
|
||||
Assert.False(RoomCrypto.TryDecryptText(ciphertext, RoomCrypto.GenerateRoomKey(), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryDecryptText_PlainText_ReturnsFalse()
|
||||
{
|
||||
Assert.False(RoomCrypto.TryDecryptText("just a normal message", RoomCrypto.GenerateRoomKey(), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncryptBytes_RoundTrips()
|
||||
{
|
||||
var key = RoomCrypto.GenerateRoomKey();
|
||||
var payload = new byte[4096];
|
||||
Random.Shared.NextBytes(payload);
|
||||
|
||||
var blob = RoomCrypto.EncryptBytes(payload, key);
|
||||
var decrypted = RoomCrypto.DecryptBytes(blob, key);
|
||||
|
||||
Assert.Equal(payload, decrypted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeriveKeys_IsDeterministic_AndSaltSensitive()
|
||||
{
|
||||
var salt = RoomCrypto.GenerateSalt();
|
||||
var a = RoomCrypto.DeriveKeys("correct horse battery staple", salt);
|
||||
var b = RoomCrypto.DeriveKeys("correct horse battery staple", salt);
|
||||
var other = RoomCrypto.DeriveKeys("correct horse battery staple", RoomCrypto.GenerateSalt());
|
||||
|
||||
Assert.Equal(a.AuthKeyHex, b.AuthKeyHex);
|
||||
Assert.Equal(a.KeyEncryptionKey, b.KeyEncryptionKey);
|
||||
Assert.NotEqual(a.AuthKeyHex, other.AuthKeyHex);
|
||||
Assert.NotEqual(a.AuthKeyHex, Convert.ToHexString(a.KeyEncryptionKey).ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WrapRoomKey_UnwrapsWithSameKek_FailsWithWrongKek()
|
||||
{
|
||||
var salt = RoomCrypto.GenerateSalt();
|
||||
var keys = RoomCrypto.DeriveKeys("passphrase-1", salt);
|
||||
var wrongKeys = RoomCrypto.DeriveKeys("passphrase-2", salt);
|
||||
var roomKey = RoomCrypto.GenerateRoomKey();
|
||||
|
||||
var wrapped = RoomCrypto.WrapRoomKey(roomKey, keys.KeyEncryptionKey);
|
||||
|
||||
Assert.True(RoomCrypto.TryUnwrapRoomKey(wrapped, keys.KeyEncryptionKey, out var unwrapped));
|
||||
Assert.Equal(roomKey, unwrapped);
|
||||
Assert.False(RoomCrypto.TryUnwrapRoomKey(wrapped, wrongKeys.KeyEncryptionKey, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rewrap_PreservesRoomKey_AcrossPassphraseChange()
|
||||
{
|
||||
// Simulates a passphrase change: unwrap with old KEK, wrap with new KEK.
|
||||
var roomKey = RoomCrypto.GenerateRoomKey();
|
||||
|
||||
var oldSalt = RoomCrypto.GenerateSalt();
|
||||
var oldKeys = RoomCrypto.DeriveKeys("old-passphrase", oldSalt);
|
||||
var wrappedOld = RoomCrypto.WrapRoomKey(roomKey, oldKeys.KeyEncryptionKey);
|
||||
|
||||
Assert.True(RoomCrypto.TryUnwrapRoomKey(wrappedOld, oldKeys.KeyEncryptionKey, out var recovered));
|
||||
|
||||
var newSalt = RoomCrypto.GenerateSalt();
|
||||
var newKeys = RoomCrypto.DeriveKeys("new-passphrase", newSalt);
|
||||
var wrappedNew = RoomCrypto.WrapRoomKey(recovered, newKeys.KeyEncryptionKey);
|
||||
|
||||
Assert.True(RoomCrypto.TryUnwrapRoomKey(wrappedNew, newKeys.KeyEncryptionKey, out var final));
|
||||
Assert.Equal(roomKey, final);
|
||||
|
||||
// Old messages encrypted before the change still decrypt with the unwrapped key
|
||||
var oldMessage = RoomCrypto.EncryptText("written before rekey", roomKey);
|
||||
Assert.True(RoomCrypto.TryDecryptText(oldMessage, final, out var plaintext));
|
||||
Assert.Equal("written before rekey", plaintext);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user