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()
{