diff --git a/README.md b/README.md index fe31d4f..a56de2c 100644 --- a/README.md +++ b/README.md @@ -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 [password]` | Join a channel (password for protected channels) | +| `/join [password]` | Join a channel (passphrase for encrypted channels) | +| `/passwd ` | Change the current encrypted channel's passphrase (creator only) | | `/leave` | Leave current channel | | `/topic ` | Set channel topic (creator only) | | `/send ` | Upload a file or image | diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 11a614c..e35c226 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -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 diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml index 85d2776..afef17b 100644 --- a/docs/changelog/toc.yml +++ b/docs/changelog/toc.yml @@ -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 diff --git a/docs/changelog/v0.2.12.md b/docs/changelog/v0.2.12.md index 9432219..ce425e1 100644 --- a/docs/changelog/v0.2.12.md +++ b/docs/changelog/v0.2.12.md @@ -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 ` (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 [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 ` 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 ` 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) diff --git a/docs/todo.md b/docs/todo.md index ee89ff5..4eed91c 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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 diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index d1d63e8..b742136 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -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 } } + /// + /// 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. + /// + 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 /// /// 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. /// private async Task?> 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 } } + /// + /// 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. + /// + private async Task?> 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(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."; + } + } + + /// + /// 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. + /// + 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"); } + /// + /// 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). + /// + private async Task 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"); + } + /// /// 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)) diff --git a/src/EchoHub.Client/Commands/CommandHandler.cs b/src/EchoHub.Client/Commands/CommandHandler.cs index 4e26c84..52d8caf 100644 --- a/src/EchoHub.Client/Commands/CommandHandler.cs +++ b/src/EchoHub.Client/Commands/CommandHandler.cs @@ -14,6 +14,7 @@ public class CommandHandler public event Func? OnOpenProfile; public event Func? OnOpenServers; public event Func? OnJoinChannel; + public event Func? OnChangeRoomPassword; public event Func? OnLeaveChannel; public event Func? OnSetTopic; public event Func? 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 HandlePasswd(string args) + { + var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length != 2) + return new CommandResult(true, "Usage: /passwd — 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 HandleLeave() { if (OnLeaveChannel is not null) @@ -347,6 +363,7 @@ public class CommandHandler /profile [username] - View a profile /servers - Open saved servers /join [password] - Join a channel (password if protected) + /passwd - Change current encrypted channel's passphrase /leave - Leave current channel /topic - Set channel topic /users - List online users diff --git a/src/EchoHub.Client/Config/ClientConfig.cs b/src/EchoHub.Client/Config/ClientConfig.cs index 02154bd..15b6b60 100644 --- a/src/EchoHub.Client/Config/ClientConfig.cs +++ b/src/EchoHub.Client/Config/ClientConfig.cs @@ -23,6 +23,13 @@ public class SavedServer public string? RefreshToken { get; set; } public bool RememberMe { get; set; } public DateTimeOffset LastConnected { get; set; } + + /// + /// 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. + /// + public Dictionary ChannelKeys { get; set; } = []; } public class AccountPreset diff --git a/src/EchoHub.Client/Services/ApiClient.cs b/src/EchoHub.Client/Services/ApiClient.cs index 3c689a8..cf64417 100644 --- a/src/EchoHub.Client/Services/ApiClient.cs +++ b/src/EchoHub.Client/Services/ApiClient.cs @@ -185,7 +185,8 @@ public sealed class ApiClient : IDisposable return result?.AvatarAscii; } - public async Task UploadFileAsync(string channelName, Stream fileStream, string fileName, string? size = null) + public async Task 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 CreateChannelAsync(string name, string? topic = null, bool isPublic = true, string? password = null) + public async Task 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(); } + /// + /// 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. + /// + public async Task 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(); + } + + public async Task 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(); + } + public async Task UpdateChannelTopicAsync(string channelName, string? topic) { EnsureAuthenticated(); diff --git a/src/EchoHub.Client/Services/ConnectionManager.cs b/src/EchoHub.Client/Services/ConnectionManager.cs index 6b30aa2..ec4e83b 100644 --- a/src/EchoHub.Client/Services/ConnectionManager.cs +++ b/src/EchoHub.Client/Services/ConnectionManager.cs @@ -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 _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(); } /// @@ -169,14 +173,14 @@ internal sealed class ConnectionManager : IAsyncDisposable // ── Channel Operations ──────────────────────────────────────────────── - public async Task> JoinChannelAsync(string channelName, string? password = null) + public async Task 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) { diff --git a/src/EchoHub.Client/Services/EchoHubConnection.cs b/src/EchoHub.Client/Services/EchoHubConnection.cs index 995dfbb..0724fc3 100644 --- a/src/EchoHub.Client/Services/EchoHubConnection.cs +++ b/src/EchoHub.Client/Services/EchoHubConnection.cs @@ -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; +/// +/// Result of joining a channel: decrypted history plus, for end-to-end encrypted +/// channels, the key envelope needed to unlock the room content key. +/// +public sealed record JoinOutcome(List History, string? EncryptionSalt, string? WrappedRoomKey); + /// /// 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? OnMessageReceived; public event Action? 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(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(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username, presence) => @@ -148,7 +158,7 @@ public sealed class EchoHubConnection : IAsyncDisposable OnConnectionStateChanged?.Invoke("Disconnected"); } - public async Task> JoinChannelAsync(string channelName, string? password = null) + public async Task JoinChannelAsync(string channelName, string? password = null) { var result = await _connection.InvokeAsync("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 DecryptMessages(List messages) { - return messages.Select(m => m with { Content = _encryption.Decrypt(m.Content) }).ToList(); + return messages.Select(DecryptMessage).ToList(); + } + + /// + /// 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. + /// + 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() diff --git a/src/EchoHub.Client/Services/RoomKeyStore.cs b/src/EchoHub.Client/Services/RoomKeyStore.cs new file mode 100644 index 0000000..b274bcc --- /dev/null +++ b/src/EchoHub.Client/Services/RoomKeyStore.cs @@ -0,0 +1,109 @@ +using EchoHub.Client.Config; +using Serilog; + +namespace EchoHub.Client.Services; + +/// +/// 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. +/// +public sealed class RoomKeyStore +{ + private readonly Dictionary _keys = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _lock = new(); + private string? _serverUrl; + + /// Binds the store to a server and loads that server's cached keys from config. + 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 _); + + /// Stores a key for the session and persists it to the server's config entry. + 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 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)); +} diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs index e8b32c1..fdda23e 100644 --- a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs +++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs @@ -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 + { + 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: diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 1404ce9..d6cf1de 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -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 /// public event Action? OnFileDownloadRequested; + /// + /// Fired when the user activates an image's "[save original]" line. Parameters: attachmentUrl, fileName. + /// + public event Action? OnImageSaveRequested; + /// /// Fired when the user activates a username (in userlist or message). Parameter is the username. /// @@ -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(); diff --git a/src/EchoHub.Core/Contracts/IChannelService.cs b/src/EchoHub.Core/Contracts/IChannelService.cs index bf6cb74..948d427 100644 --- a/src/EchoHub.Core/Contracts/IChannelService.cs +++ b/src/EchoHub.Core/Contracts/IChannelService.cs @@ -6,15 +6,20 @@ public interface IChannelService { // Channel CRUD Task> GetChannelsAsync(Guid userId, int offset, int limit); - Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null); + Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, + string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null); Task UpdateTopicAsync(Guid callerUserId, string channelName, string? topic); Task SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password); + Task RekeyChannelAsync(Guid callerUserId, string channelName, + string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey); Task DeleteChannelAsync(Guid callerUserId, string channelName); // Channel queries Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName); Task> GetChannelListAsync(); Task GetChannelByNameAsync(string channelName); + Task 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); diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index 694f1a4..02a1a4b 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -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); + +/// +/// Public crypto metadata for a channel — enough for a client to derive its join +/// credential from a passphrase. Never includes the wrapped room key. +/// +public record ChannelCryptoDto(bool IsEncrypted, string? EncryptionSalt); + +/// +/// 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. +/// +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 History, string? Error = null, bool PasswordRequired = false); +public record JoinChannelResult( + bool Success, + List History, + string? Error = null, + bool PasswordRequired = false, + string? EncryptionSalt = null, + string? WrappedRoomKey = null); public record EmbedDto( string? SiteName, diff --git a/src/EchoHub.Core/EchoHub.Core.csproj b/src/EchoHub.Core/EchoHub.Core.csproj index b760144..e8fc813 100644 --- a/src/EchoHub.Core/EchoHub.Core.csproj +++ b/src/EchoHub.Core/EchoHub.Core.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -6,4 +6,8 @@ enable + + + + diff --git a/src/EchoHub.Core/Models/Channel.cs b/src/EchoHub.Core/Models/Channel.cs index b305705..27d7c06 100644 --- a/src/EchoHub.Core/Models/Channel.cs +++ b/src/EchoHub.Core/Models/Channel.cs @@ -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; } diff --git a/src/EchoHub.Core/Security/RoomCrypto.cs b/src/EchoHub.Core/Security/RoomCrypto.cs new file mode 100644 index 0000000..f4d3a8b --- /dev/null +++ b/src/EchoHub.Core/Security/RoomCrypto.cs @@ -0,0 +1,132 @@ +using System.Security.Cryptography; +using System.Text; + +namespace EchoHub.Core.Security; + +/// +/// 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). +/// +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); + + /// + /// Derives the auth key (join gate credential) and key-encryption key from a passphrase. + /// + 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); + } + + /// Encrypts UTF-8 text with the room key. Output: $RC1$base64(nonce||tag||ciphertext). + public static string EncryptText(string plaintext, byte[] key) => + CiphertextPrefix + Convert.ToBase64String(EncryptBytes(Encoding.UTF8.GetBytes(plaintext), key)); + + /// + /// Decrypts text produced by . Returns false when the input + /// is not room ciphertext or the key does not match. + /// + 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); + + /// Encrypts a binary blob (file contents) with the room key: nonce||tag||ciphertext. + 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; + } + + /// Decrypts a blob produced by . Throws on key mismatch. + 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; + } + + /// Wraps the room content key under the key-encryption key for server storage. + public static string WrapRoomKey(byte[] roomKey, byte[] kek) => + Convert.ToBase64String(EncryptBytes(roomKey, kek)); + + /// Unwraps the stored room content key. Returns false when the KEK (passphrase) is wrong. + 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; + } + } +} diff --git a/src/EchoHub.Core/Services/FileValidationHelper.cs b/src/EchoHub.Core/Services/FileValidationHelper.cs index f1d3b40..ce606de 100644 --- a/src/EchoHub.Core/Services/FileValidationHelper.cs +++ b/src/EchoHub.Core/Services/FileValidationHelper.cs @@ -1,4 +1,4 @@ -namespace EchoHub.Server.Services; +namespace EchoHub.Core.Services; public static class FileValidationHelper { diff --git a/src/EchoHub.Core/Services/ImageToAsciiService.cs b/src/EchoHub.Core/Services/ImageToAsciiService.cs index a1520de..770f648 100644 --- a/src/EchoHub.Core/Services/ImageToAsciiService.cs +++ b/src/EchoHub.Core/Services/ImageToAsciiService.cs @@ -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 { diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs index 3cdc77a..0e27201 100644 --- a/src/EchoHub.Server.Irc/IrcCommandHandler.cs +++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs @@ -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); diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 978d65a..c9b4088 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -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); } + /// + /// 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. + /// + [HttpGet("{channel}/crypto")] + public async Task 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); + } + + /// + /// 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). + /// + [HttpPost("{channel}/rekey")] + public async Task 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 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.")); diff --git a/src/EchoHub.Server/Controllers/UsersController.cs b/src/EchoHub.Server/Controllers/UsersController.cs index 8621454..55e4970 100644 --- a/src/EchoHub.Server/Controllers/UsersController.cs +++ b/src/EchoHub.Server/Controllers/UsersController.cs @@ -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; diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs index efd244a..3039dbd 100644 --- a/src/EchoHub.Server/Data/EchoHubDbContext.cs +++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs @@ -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) diff --git a/src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.Designer.cs new file mode 100644 index 0000000..458545a --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.Designer.cs @@ -0,0 +1,279 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("EncryptionSalt") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WrappedRoomKey") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentFileSize") + .HasColumnType("INTEGER"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(16000) + .HasColumnType("TEXT"); + + b.Property("EmbedJson") + .HasMaxLength(32000) + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsMuted") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.cs b/src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.cs new file mode 100644 index 0000000..a0a6664 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260716012917_AddChannelEncryptionEnvelope.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class AddChannelEncryptionEnvelope : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EncryptionSalt", + table: "Channels", + type: "TEXT", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "WrappedRoomKey", + table: "Channels", + type: "TEXT", + maxLength: 200, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EncryptionSalt", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "WrappedRoomKey", + table: "Channels"); + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs index c9fcf62..0cba3e3 100644 --- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -29,6 +29,10 @@ namespace EchoHub.Server.Data.Migrations b.Property("CreatedByUserId") .HasColumnType("TEXT"); + b.Property("EncryptionSalt") + .HasMaxLength(64) + .HasColumnType("TEXT"); + b.Property("IsPublic") .HasColumnType("INTEGER"); @@ -45,6 +49,10 @@ namespace EchoHub.Server.Data.Migrations .HasMaxLength(500) .HasColumnType("TEXT"); + b.Property("WrappedRoomKey") + .HasMaxLength(200) + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("Name") diff --git a/src/EchoHub.Server/Hubs/ChatHub.cs b/src/EchoHub.Server/Hubs/ChatHub.cs index 466778b..8f0cc68 100644 --- a/src/EchoHub.Server/Hubs/ChatHub.cs +++ b/src/EchoHub.Server/Hubs/ChatHub.cs @@ -14,9 +14,12 @@ public class ChatHub : Hub private readonly IChatService _chatService; private readonly ILogger _logger; - public ChatHub(IChatService chatService, ILogger logger) + private readonly IChannelService _channelService; + + public ChatHub(IChatService chatService, IChannelService channelService, ILogger logger) { _chatService = chatService; + _channelService = channelService; _logger = logger; } @@ -67,7 +70,12 @@ public class ChatHub : Hub 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) { diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index 7693bd9..66434f3 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -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; diff --git a/src/EchoHub.Server/Services/ChannelService.cs b/src/EchoHub.Server/Services/ChannelService.cs index 65aa692..305dbfe 100644 --- a/src/EchoHub.Server/Services/ChannelService.cs +++ b/src/EchoHub.Server/Services/ChannelService.cs @@ -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(channels, total, offset, limit); } public async Task 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(); @@ -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); } /// /// 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 + /// so the room key envelope stays consistent. /// public async Task 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); + } + + /// + /// 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. + /// + public async Task 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(); + + 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 GetChannelCryptoAsync(string channelName) + { + channelName = channelName.ToLowerInvariant().Trim(); + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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(); + + 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( diff --git a/src/EchoHub.Tests/FileValidationHelperTests.cs b/src/EchoHub.Tests/FileValidationHelperTests.cs index 59bc283..1066339 100644 --- a/src/EchoHub.Tests/FileValidationHelperTests.cs +++ b/src/EchoHub.Tests/FileValidationHelperTests.cs @@ -1,4 +1,4 @@ -using EchoHub.Server.Services; +using EchoHub.Core.Services; using Xunit; namespace EchoHub.Tests; diff --git a/src/EchoHub.Tests/ImageToAsciiServiceTests.cs b/src/EchoHub.Tests/ImageToAsciiServiceTests.cs index 9e9238c..c8232d0 100644 --- a/src/EchoHub.Tests/ImageToAsciiServiceTests.cs +++ b/src/EchoHub.Tests/ImageToAsciiServiceTests.cs @@ -1,4 +1,5 @@ using EchoHub.Core.Constants; +using EchoHub.Core.Services; using EchoHub.Server.Services; using Xunit; diff --git a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs index d6b641f..f0ac952 100644 --- a/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs +++ b/src/EchoHub.Tests/Irc/IrcCommandHandlerTests.cs @@ -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() { diff --git a/src/EchoHub.Tests/Irc/TestHelpers.cs b/src/EchoHub.Tests/Irc/TestHelpers.cs index d726132..7de0906 100644 --- a/src/EchoHub.Tests/Irc/TestHelpers.cs +++ b/src/EchoHub.Tests/Irc/TestHelpers.cs @@ -234,9 +234,24 @@ internal sealed class FakeChannelService : IChannelService public Task> GetChannelsAsync(Guid userId, int offset, int limit) => Task.FromResult(new PaginatedResponse([], 0, offset, limit)); - public Task 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 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 GetChannelCryptoAsync(string channelName) => + Task.FromResult(CryptoToReturn); + + public Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName) => + Task.FromResult(KeyEnvelopeToReturn); + + public Task RekeyChannelAsync(Guid callerUserId, string channelName, + string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey) => + Task.FromResult(RekeyResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); + public Task UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) => Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); diff --git a/src/EchoHub.Tests/RoomCryptoTests.cs b/src/EchoHub.Tests/RoomCryptoTests.cs new file mode 100644 index 0000000..0bc6855 --- /dev/null +++ b/src/EchoHub.Tests/RoomCryptoTests.cs @@ -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); + } +}