diff --git a/src/EchoHub.Core/Contracts/IMessageEncryptionService.cs b/src/EchoHub.Core/Contracts/IMessageEncryptionService.cs index 5c3f3aa..0db7f8a 100644 --- a/src/EchoHub.Core/Contracts/IMessageEncryptionService.cs +++ b/src/EchoHub.Core/Contracts/IMessageEncryptionService.cs @@ -2,6 +2,12 @@ namespace EchoHub.Core.Contracts; public interface IMessageEncryptionService { + /// + /// Prefix marking transport/at-rest encrypted content. is a + /// pass-through for values without it. + /// + const string CiphertextPrefix = "$ENC$v1$"; + /// /// Whether database content should be encrypted at rest (server setting). /// diff --git a/src/EchoHub.Server.Irc/IrcBroadcaster.cs b/src/EchoHub.Server.Irc/IrcBroadcaster.cs index 07b41e4..1d8d496 100644 --- a/src/EchoHub.Server.Irc/IrcBroadcaster.cs +++ b/src/EchoHub.Server.Irc/IrcBroadcaster.cs @@ -16,8 +16,16 @@ public class IrcBroadcaster : IChatBroadcaster public async Task SendMessageToChannelAsync(string channelName, MessageDto message) { - // Decrypt content for IRC clients (they can't handle app-layer encryption) - var decryptedMessage = message with { Content = _encryption.Decrypt(message.Content) }; + // Decrypt content and attachment previews for IRC clients (they can't handle + // app-layer encryption). E2E room ciphertext ($RC1$) passes through untouched; + // the formatter drops previews that are still ciphertext. + var decryptedMessage = message with + { + Content = _encryption.Decrypt(message.Content), + Attachments = message.Attachments? + .Select(a => a with { AsciiPreview = _encryption.DecryptNullable(a.AsciiPreview) }) + .ToList(), + }; var lines = IrcMessageFormatter.FormatMessage(decryptedMessage); foreach (var conn in _gateway.GetConnectionsInChannel(channelName)) diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs index 62e39cc..01b4ab5 100644 --- a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs +++ b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs @@ -1,7 +1,9 @@ using System.Text; using System.Text.RegularExpressions; +using EchoHub.Core.Contracts; using EchoHub.Core.DTOs; using EchoHub.Core.Models; +using EchoHub.Core.Security; namespace EchoHub.Server.Irc; @@ -34,7 +36,7 @@ public static partial class IrcMessageFormatter { case AttachmentKind.Image: lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {attachment.FileName}] {attachment.Url}"); - if (attachment.AsciiPreview is not null) + if (attachment.AsciiPreview is not null && !IsCiphertext(attachment.AsciiPreview)) { foreach (var line in attachment.AsciiPreview.Split('\n')) { @@ -66,6 +68,15 @@ public static partial class IrcMessageFormatter return lines; } + /// + /// True when a preview is still encrypted — transport ($ENC$v1$) if a broadcast path + /// forgot to decrypt it, or E2E room ciphertext ($RC1$) the server cannot decrypt. + /// Emitting it would flood IRC clients with a multi-KB base64 blob. + /// + private static bool IsCiphertext(string text) => + text.StartsWith(IMessageEncryptionService.CiphertextPrefix, StringComparison.Ordinal) + || text.StartsWith(RoomCrypto.CiphertextPrefix, StringComparison.Ordinal); + /// /// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail). /// diff --git a/src/EchoHub.Server/Services/MessageEncryptionService.cs b/src/EchoHub.Server/Services/MessageEncryptionService.cs index a277cf0..a3f8053 100644 --- a/src/EchoHub.Server/Services/MessageEncryptionService.cs +++ b/src/EchoHub.Server/Services/MessageEncryptionService.cs @@ -8,7 +8,7 @@ namespace EchoHub.Server.Services; public class MessageEncryptionService : IMessageEncryptionService { - private const string EncryptionPrefix = "$ENC$v1$"; + private const string EncryptionPrefix = IMessageEncryptionService.CiphertextPrefix; private const int NonceSizeBytes = 12; private const int TagSizeBytes = 16; diff --git a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs index 8547285..0e08d82 100644 --- a/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs +++ b/src/EchoHub.Tests/Irc/IrcBroadcasterTests.cs @@ -89,6 +89,27 @@ public class IrcBroadcasterTests Assert.DoesNotContain(output, l => l.Contains("$ENC$")); } + [Fact] + public async Task SendMessage_DecryptsAttachmentAsciiPreview() + { + var (_, stream) = AddConnectionWithCapture("bob", "general"); + + var attachment = new AttachmentDto( + AttachmentKind.Image, "/api/files/abc", "photo.png", 1234, + _encryption.Encrypt("line1\nline2")); + var message = new MessageDto( + Guid.NewGuid(), _encryption.Encrypt("look at this"), "alice", null, "general", + DateTimeOffset.UtcNow, [attachment]); + + await _broadcaster.SendMessageToChannelAsync("general", message); + + var output = stream.GetOutputLines(); + Assert.Contains(output, l => l.Contains("[Image: photo.png]")); + Assert.Contains(output, l => l.Contains("line1")); + Assert.Contains(output, l => l.Contains("line2")); + Assert.DoesNotContain(output, l => l.Contains("$ENC$")); + } + [Fact] public async Task SendMessage_SkipsSender() { diff --git a/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs b/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs index 8f73265..29736bc 100644 --- a/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs +++ b/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs @@ -138,6 +138,19 @@ public class IrcMessageFormatterTests Assert.Equal(2, asciiLines.Count); } + [Theory] + [InlineData("$ENC$v1$abc123$def456")] // transport ciphertext a broadcast path forgot to decrypt + [InlineData("$RC1$abc123def456")] // E2E room ciphertext the server cannot decrypt + public void FormatMessage_ImageMessage_SkipsCiphertextPreview(string ciphertextPreview) + { + var msg = CreateImageMessage(ciphertextPreview, "photo.jpg", "https://example.com/photo.jpg"); + var lines = IrcMessageFormatter.FormatMessage(msg); + + // Only the [Image: ...] header line — never the ciphertext blob + Assert.Single(lines); + Assert.Contains("[Image: photo.jpg]", lines[0]); + } + [Fact] public void FormatMessage_FileMessage_FormatsCorrectly() {