mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
Merge pull request #7 from HueByte/dev_e2e_encrypt
feat: Implement message encryption and decryption support
This commit is contained in:
+3
-3
@@ -3,19 +3,19 @@
|
|||||||
- name: API Reference
|
- name: API Reference
|
||||||
href: ../_api_meta/client/toc.yml
|
href: ../_api_meta/client/toc.yml
|
||||||
- name: Articles
|
- name: Articles
|
||||||
href: client-articles/
|
href: client-articles/toc.yml
|
||||||
- name: Core
|
- name: Core
|
||||||
items:
|
items:
|
||||||
- name: API Reference
|
- name: API Reference
|
||||||
href: ../_api_meta/core/toc.yml
|
href: ../_api_meta/core/toc.yml
|
||||||
- name: Articles
|
- name: Articles
|
||||||
href: core-articles/
|
href: core-articles/toc.yml
|
||||||
- name: Server
|
- name: Server
|
||||||
items:
|
items:
|
||||||
- name: API Reference
|
- name: API Reference
|
||||||
href: ../_api_meta/server/toc.yml
|
href: ../_api_meta/server/toc.yml
|
||||||
- name: Articles
|
- name: Articles
|
||||||
href: server-articles/
|
href: server-articles/toc.yml
|
||||||
- name: Server.Irc
|
- name: Server.Irc
|
||||||
items:
|
items:
|
||||||
- name: API Reference
|
- name: API Reference
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Message Encryption
|
||||||
|
|
||||||
|
EchoHub uses application-layer AES-256-GCM encryption to protect message content in transit between clients and the server. This is an additional layer on top of TLS, protecting against ISPs, proxies, and any middleman that can inspect HTTPS traffic (e.g. corporate proxies with trusted root CA certificates).
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Sender as TUI Client (Sender)
|
||||||
|
participant Server
|
||||||
|
participant Receiver as TUI Client (Receiver)
|
||||||
|
participant IRC as IRC Client
|
||||||
|
|
||||||
|
Sender->>Sender: encrypt(plaintext)
|
||||||
|
Sender->>Server: $ENC$v1$... (SignalR)
|
||||||
|
Server->>Server: decrypt → validate/sanitize
|
||||||
|
Server->>Server: fetch embeds on plaintext
|
||||||
|
Server->>Server: (optional) encrypt for DB storage
|
||||||
|
Server->>Server: encrypt(plaintext) with fresh nonce
|
||||||
|
Server->>Receiver: $ENC$v1$... (SignalR broadcast)
|
||||||
|
Receiver->>Receiver: decrypt → display
|
||||||
|
Server->>Server: decrypt for IRC
|
||||||
|
Server->>IRC: plaintext (IRC PRIVMSG)
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Client encrypts** the message before sending it over SignalR
|
||||||
|
2. **Server decrypts** to validate content, sanitize newlines, and fetch link embeds
|
||||||
|
3. **Server re-encrypts** with a fresh nonce and broadcasts to all connected SignalR clients
|
||||||
|
4. **Clients decrypt** the broadcast and display the plaintext
|
||||||
|
5. **IRC clients** receive plaintext automatically (the IRC broadcaster decrypts before forwarding)
|
||||||
|
|
||||||
|
Each encryption uses a random 12-byte nonce, so the same message produces different ciphertext every time.
|
||||||
|
|
||||||
|
## Encryption Key
|
||||||
|
|
||||||
|
A 256-bit AES key is auto-generated on first server startup and saved to `appsettings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Encryption": {
|
||||||
|
"Key": "base64-encoded-32-byte-key",
|
||||||
|
"EncryptDatabase": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The key is generated by `FirstRunSetup` using `RandomNumberGenerator.GetBytes(32)`. If the key is missing or empty when the server starts, a new one is created automatically.
|
||||||
|
|
||||||
|
Clients fetch the key after login via an authenticated endpoint (`GET /api/server/encryption-key`). No manual configuration is needed on the client side.
|
||||||
|
|
||||||
|
## Encrypted Content Format
|
||||||
|
|
||||||
|
Encrypted content uses a self-describing format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ENC$v1${nonce_base64}${ciphertext+tag_base64}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`$ENC$v1$`** — version prefix (allows future algorithm changes)
|
||||||
|
- **Nonce** — 12 bytes, Base64-encoded, randomly generated per message
|
||||||
|
- **Ciphertext + Tag** — AES-256-GCM output with 16-byte authentication tag appended
|
||||||
|
|
||||||
|
The authentication tag ensures both confidentiality and integrity — any tampering with the ciphertext is detected during decryption.
|
||||||
|
|
||||||
|
## Database Encryption (Optional)
|
||||||
|
|
||||||
|
By default, messages are stored as **plaintext** in the database. Transport encryption still protects everything on the wire, but the SQLite file itself contains readable messages.
|
||||||
|
|
||||||
|
To encrypt messages at rest, enable the setting:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Encryption": {
|
||||||
|
"Key": "...",
|
||||||
|
"EncryptDatabase": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behavior by Setting
|
||||||
|
|
||||||
|
| Setting | DB Storage | Transport | Key Rotation Risk |
|
||||||
|
| ----------------- | ---------- | --------- | ---------------------------------------------- |
|
||||||
|
| `false` (default) | Plaintext | Encrypted | None - stored data is unaffected |
|
||||||
|
| `true` | Encrypted | Encrypted | Changing the key makes old messages unreadable |
|
||||||
|
|
||||||
|
### Mixed Content
|
||||||
|
|
||||||
|
The server handles mixed encrypted/plaintext content in the database gracefully. When reading messages:
|
||||||
|
|
||||||
|
- Content starting with `$ENC$v1$` is decrypted
|
||||||
|
- Everything else is treated as plaintext
|
||||||
|
|
||||||
|
This means you can safely toggle `EncryptDatabase` at any time. Old messages remain readable regardless of the current setting.
|
||||||
|
|
||||||
|
### Enabling Encryption at Rest
|
||||||
|
|
||||||
|
When you set `EncryptDatabase: true`, only **new messages** are encrypted going forward. Existing plaintext messages in the database are not retroactively encrypted. This is intentional — it keeps key rotation safe and avoids irreversible bulk changes.
|
||||||
|
|
||||||
|
### Key Rotation
|
||||||
|
|
||||||
|
Changing the encryption key is safe:
|
||||||
|
|
||||||
|
- **Plaintext messages** — always readable regardless of key
|
||||||
|
- **Messages encrypted with the old key** — will show `[encrypted message — decryption failed]`
|
||||||
|
- **New messages** — encrypted with the new key going forward
|
||||||
|
|
||||||
|
If you need to recover old encrypted messages, restore the original key from a backup of `appsettings.json`. Since plaintext messages are never retroactively encrypted, you'll never lose access to your entire history from a key change.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### What This Protects Against
|
||||||
|
|
||||||
|
- **Passive network sniffing** — messages are encrypted even if captured off the wire
|
||||||
|
- **ISP/proxy inspection** — content is encrypted at the application layer, independent of TLS
|
||||||
|
- **Database theft** (when `EncryptDatabase: true`) — SQLite file contains only ciphertext
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
|
||||||
|
- **TLS-inspecting proxies** — if a corporate proxy terminates TLS with a trusted root CA, it can intercept the key exchange (`GET /api/server/encryption-key`) and read all traffic. A future upgrade to ECDH key exchange would address this.
|
||||||
|
- **Server has full access** — the server decrypts all messages for processing. This is not end-to-end encryption between users; it's transport encryption between client and server.
|
||||||
|
- **IRC clients receive plaintext** — IRC is an open protocol and third-party clients cannot participate in the encryption scheme.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Messages show `[encrypted message — decryption failed, try re-logging to fetch the latest key]`
|
||||||
|
|
||||||
|
The client's encryption key doesn't match the server's. This happens when:
|
||||||
|
|
||||||
|
- The server's encryption key was rotated while the client was connected
|
||||||
|
- The client cached a stale key
|
||||||
|
|
||||||
|
**Fix**: Disconnect and reconnect (re-login). The client fetches the current key on each login.
|
||||||
|
|
||||||
|
### Messages show `[encrypted message — decryption failed]` in channel history
|
||||||
|
|
||||||
|
The server cannot decrypt messages stored in the database. This happens when:
|
||||||
|
|
||||||
|
- `EncryptDatabase` was enabled, and the key was changed afterwards
|
||||||
|
|
||||||
|
**Fix**: Restore the original key from a backup. There is no way to recover messages encrypted with a lost key.
|
||||||
@@ -2,3 +2,7 @@
|
|||||||
href: getting-started.md
|
href: getting-started.md
|
||||||
- name: Architecture
|
- name: Architecture
|
||||||
href: architecture.md
|
href: architecture.md
|
||||||
|
- name: Encryption
|
||||||
|
href: encryption.md
|
||||||
|
- name: Notification Sounds
|
||||||
|
href: notification-sounds.md
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
- name: Overview
|
- name: Overview
|
||||||
href: index.md
|
href: index.md
|
||||||
|
- name: v0.2.4
|
||||||
|
href: v0.2.4.md
|
||||||
- name: v0.2.3
|
- name: v0.2.3
|
||||||
href: v0.2.3.md
|
href: v0.2.3.md
|
||||||
- name: v0.2.2
|
- name: v0.2.2
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# v0.2.4 - E2E Message Encryption
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### E2E Message Encryption
|
||||||
|
|
||||||
|
- Application-layer AES-256-GCM encryption for all message content between client and server
|
||||||
|
- Protects against ISPs, proxies, and any middleman reading chat messages — even if TLS is compromised
|
||||||
|
- 256-bit encryption key auto-generated on first server startup and saved to `appsettings.json`
|
||||||
|
- Client fetches the encryption key automatically after login via `GET /api/server/encryption-key`
|
||||||
|
- Client encrypts messages before sending via SignalR; server decrypts for validation and processing
|
||||||
|
- Server broadcasts encrypted content to SignalR clients; client decrypts transparently — no user action required
|
||||||
|
- Optional database encryption at rest via `Encryption:EncryptDatabase` setting (disabled by default)
|
||||||
|
- When enabled: new messages encrypted before DB storage (existing plaintext messages are not retroactively encrypted)
|
||||||
|
- When disabled: messages stored as plaintext, no risk of data loss from key rotation
|
||||||
|
- Reads handle mixed content (encrypted + plaintext) regardless of setting — safe to toggle at any time
|
||||||
|
- Key rotation is safe: old plaintext stays readable, new messages use the new key
|
||||||
|
- IRC gateway automatically decrypts messages before forwarding to IRC clients (plaintext over IRC)
|
||||||
|
- Encrypted content format: `$ENC$v1${nonce}${ciphertext+tag}` (Base64, 12-byte nonce, 16-byte auth tag)
|
||||||
|
- Server strips `$ENC$` prefix from user-typed messages to prevent format spoofing
|
||||||
|
- Graceful fallback: if decryption fails, shows `[encrypted message — decryption failed, try re-logging to fetch the latest key]`
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- `IMessageEncryptionService` interface in Core; `MessageEncryptionService` server implementation and `ClientEncryptionService` client implementation
|
||||||
|
- `EncryptionKeyResponse` DTO and `GET /api/server/encryption-key` endpoint (authenticated, rate-limited)
|
||||||
|
- `FirstRunSetup.EnsureEncryptionKey()` auto-generates AES-256 key on first server run
|
||||||
|
- `Encryption:EncryptDatabase` server setting (default `false`) controls whether messages are encrypted at rest
|
||||||
|
- DB column max lengths increased for encrypted content: `Message.Content` 2000 → 16000, `Message.EmbedJson` 8000 → 32000
|
||||||
|
- EF Core migration: `AddEncryptionSupport`
|
||||||
|
- Encryption test suite: server-side, client-side, and cross-compatibility tests (87 total)
|
||||||
|
- Documentation article: `docs/articles/encryption.md`
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.2.3</Version>
|
<Version>0.2.4</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
private EchoHubConnection? _connection;
|
private EchoHubConnection? _connection;
|
||||||
private ApiClient? _apiClient;
|
private ApiClient? _apiClient;
|
||||||
|
private readonly ClientEncryptionService _encryption = new();
|
||||||
private ClientConfig _config;
|
private ClientConfig _config;
|
||||||
private UserStatus _currentStatus = UserStatus.Online;
|
private UserStatus _currentStatus = UserStatus.Online;
|
||||||
private string? _currentStatusMessage;
|
private string? _currentStatusMessage;
|
||||||
@@ -382,6 +383,19 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
|
|
||||||
_currentUsername = loginResponse.Username;
|
_currentUsername = loginResponse.Username;
|
||||||
|
|
||||||
|
// Fetch encryption key for E2E message encryption
|
||||||
|
InvokeUI(() => _mainWindow.UpdateStatusBar("Fetching encryption key..."));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var encryptionKey = await _apiClient.GetEncryptionKeyAsync();
|
||||||
|
_encryption.SetKey(encryptionKey);
|
||||||
|
Log.Information("E2E encryption key established");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to fetch encryption key — messages will not be encrypted");
|
||||||
|
}
|
||||||
|
|
||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
{
|
{
|
||||||
_mainWindow.SetCurrentUser(loginResponse.DisplayName ?? loginResponse.Username);
|
_mainWindow.SetCurrentUser(loginResponse.DisplayName ?? loginResponse.Username);
|
||||||
@@ -391,7 +405,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
if (_connection is not null)
|
if (_connection is not null)
|
||||||
await _connection.DisposeAsync();
|
await _connection.DisposeAsync();
|
||||||
|
|
||||||
_connection = new EchoHubConnection(result.ServerUrl, _apiClient);
|
_connection = new EchoHubConnection(result.ServerUrl, _apiClient, _encryption);
|
||||||
WireConnectionEvents(_connection);
|
WireConnectionEvents(_connection);
|
||||||
await _connection.ConnectAsync();
|
await _connection.ConnectAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,16 @@ public sealed class ApiClient : IDisposable
|
|||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetEncryptionKeyAsync()
|
||||||
|
{
|
||||||
|
EnsureAuthenticated();
|
||||||
|
var response = await AuthenticatedGetAsync("/api/server/encryption-key");
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<EncryptionKeyResponse>()
|
||||||
|
?? throw new InvalidOperationException("Server returned empty encryption key response.");
|
||||||
|
return result.Key;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
|
public async Task<UserProfileDto?> GetUserProfileAsync(string username)
|
||||||
{
|
{
|
||||||
EnsureAuthenticated();
|
EnsureAuthenticated();
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using EchoHub.Core.Contracts;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-side encryption service. Uses the same AES-256-GCM format as the server
|
||||||
|
/// so messages are encrypted end-to-end between client and server.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClientEncryptionService : IMessageEncryptionService
|
||||||
|
{
|
||||||
|
private const string EncryptionPrefix = "$ENC$v1$";
|
||||||
|
private const int NonceSizeBytes = 12;
|
||||||
|
private const int TagSizeBytes = 16;
|
||||||
|
|
||||||
|
private byte[]? _key;
|
||||||
|
|
||||||
|
public bool IsInitialized => _key is not null;
|
||||||
|
public bool EncryptDatabaseEnabled => false; // Not relevant for client
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initialize with the server's encryption key (fetched after login).
|
||||||
|
/// </summary>
|
||||||
|
public void SetKey(string base64Key)
|
||||||
|
{
|
||||||
|
_key = Convert.FromBase64String(base64Key);
|
||||||
|
|
||||||
|
if (_key.Length != 32)
|
||||||
|
throw new InvalidOperationException($"Encryption key must be exactly 32 bytes (256-bit). Got {_key.Length} bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Encrypt(string plaintext)
|
||||||
|
{
|
||||||
|
if (_key is null)
|
||||||
|
return plaintext; // Not initialized — pass through
|
||||||
|
|
||||||
|
var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
|
||||||
|
var nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes);
|
||||||
|
var ciphertext = new byte[plaintextBytes.Length];
|
||||||
|
var tag = new byte[TagSizeBytes];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(_key, TagSizeBytes);
|
||||||
|
aes.Encrypt(nonce, plaintextBytes, ciphertext, tag);
|
||||||
|
|
||||||
|
var combined = new byte[ciphertext.Length + tag.Length];
|
||||||
|
Buffer.BlockCopy(ciphertext, 0, combined, 0, ciphertext.Length);
|
||||||
|
Buffer.BlockCopy(tag, 0, combined, ciphertext.Length, tag.Length);
|
||||||
|
|
||||||
|
return $"{EncryptionPrefix}{Convert.ToBase64String(nonce)}${Convert.ToBase64String(combined)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Decrypt(string content)
|
||||||
|
{
|
||||||
|
if (_key is null || !content.StartsWith(EncryptionPrefix))
|
||||||
|
return content; // Not initialized or legacy plaintext
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = content[EncryptionPrefix.Length..];
|
||||||
|
var separatorIndex = payload.IndexOf('$');
|
||||||
|
if (separatorIndex < 0)
|
||||||
|
return content;
|
||||||
|
|
||||||
|
var nonceBase64 = payload[..separatorIndex];
|
||||||
|
var combinedBase64 = payload[(separatorIndex + 1)..];
|
||||||
|
|
||||||
|
var nonce = Convert.FromBase64String(nonceBase64);
|
||||||
|
var combined = Convert.FromBase64String(combinedBase64);
|
||||||
|
|
||||||
|
if (combined.Length < TagSizeBytes)
|
||||||
|
return content;
|
||||||
|
|
||||||
|
var ciphertextLength = combined.Length - TagSizeBytes;
|
||||||
|
var ciphertext = combined.AsSpan(0, ciphertextLength);
|
||||||
|
var tag = combined.AsSpan(ciphertextLength, TagSizeBytes);
|
||||||
|
var plaintext = new byte[ciphertextLength];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(_key, TagSizeBytes);
|
||||||
|
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||||
|
|
||||||
|
return Encoding.UTF8.GetString(plaintext);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "[encrypted message — decryption failed, try re-logging to fetch the latest key]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? EncryptNullable(string? value)
|
||||||
|
=> value is null ? null : Encrypt(value);
|
||||||
|
|
||||||
|
public string? DecryptNullable(string? value)
|
||||||
|
=> value is null ? null : Decrypt(value);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ namespace EchoHub.Client.Services;
|
|||||||
public sealed class EchoHubConnection : IAsyncDisposable
|
public sealed class EchoHubConnection : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly HubConnection _connection;
|
private readonly HubConnection _connection;
|
||||||
|
private readonly ClientEncryptionService _encryption;
|
||||||
|
|
||||||
public event Action<MessageDto>? OnMessageReceived;
|
public event Action<MessageDto>? OnMessageReceived;
|
||||||
public event Action<string, string>? OnUserJoined;
|
public event Action<string, string>? OnUserJoined;
|
||||||
@@ -25,8 +26,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
|
|
||||||
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
||||||
|
|
||||||
public EchoHubConnection(string serverUrl, ApiClient apiClient)
|
public EchoHubConnection(string serverUrl, ApiClient apiClient, ClientEncryptionService encryption)
|
||||||
{
|
{
|
||||||
|
_encryption = encryption;
|
||||||
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
|
var hubUrl = serverUrl.TrimEnd('/') + HubConstants.ChatHubPath;
|
||||||
|
|
||||||
_connection = new HubConnectionBuilder()
|
_connection = new HubConnectionBuilder()
|
||||||
@@ -63,7 +65,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
|
_connection.On<MessageDto>(nameof(Core.Contracts.IEchoHubClient.ReceiveMessage), message =>
|
||||||
{
|
{
|
||||||
OnMessageReceived?.Invoke(message);
|
// Decrypt message content received from server
|
||||||
|
var decrypted = message with { Content = _encryption.Decrypt(message.Content) };
|
||||||
|
OnMessageReceived?.Invoke(decrypted);
|
||||||
});
|
});
|
||||||
|
|
||||||
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) =>
|
_connection.On<string, string>(nameof(Core.Contracts.IEchoHubClient.UserJoined), (channelName, username) =>
|
||||||
@@ -132,7 +136,8 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
|
|
||||||
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
public async Task<List<MessageDto>> JoinChannelAsync(string channelName)
|
||||||
{
|
{
|
||||||
return await _connection.InvokeAsync<List<MessageDto>>("JoinChannel", channelName);
|
var messages = await _connection.InvokeAsync<List<MessageDto>>("JoinChannel", channelName);
|
||||||
|
return DecryptMessages(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LeaveChannelAsync(string channelName)
|
public async Task LeaveChannelAsync(string channelName)
|
||||||
@@ -142,12 +147,15 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
|
|
||||||
public async Task SendMessageAsync(string channelName, string content)
|
public async Task SendMessageAsync(string channelName, string content)
|
||||||
{
|
{
|
||||||
await _connection.InvokeAsync("SendMessage", channelName, content);
|
// Encrypt content before sending to server
|
||||||
|
var encrypted = _encryption.Encrypt(content);
|
||||||
|
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount)
|
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount)
|
||||||
{
|
{
|
||||||
return await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
|
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
|
||||||
|
return DecryptMessages(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateStatusAsync(UserStatus status, string? statusMessage = null)
|
public async Task UpdateStatusAsync(UserStatus status, string? statusMessage = null)
|
||||||
@@ -160,6 +168,11 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
return await _connection.InvokeAsync<List<UserPresenceDto>>("GetOnlineUsers", channelName);
|
return await _connection.InvokeAsync<List<UserPresenceDto>>("GetOnlineUsers", channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<MessageDto> DecryptMessages(List<MessageDto> messages)
|
||||||
|
{
|
||||||
|
return messages.Select(m => m with { Content = _encryption.Decrypt(m.Content) }).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await _connection.DisposeAsync();
|
await _connection.DisposeAsync();
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace EchoHub.Core.Contracts;
|
||||||
|
|
||||||
|
public interface IMessageEncryptionService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether database content should be encrypted at rest (server setting).
|
||||||
|
/// </summary>
|
||||||
|
bool EncryptDatabaseEnabled { get; }
|
||||||
|
|
||||||
|
string Encrypt(string plaintext);
|
||||||
|
string Decrypt(string content);
|
||||||
|
string? EncryptNullable(string? value);
|
||||||
|
string? DecryptNullable(string? value);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
namespace EchoHub.Core.DTOs;
|
namespace EchoHub.Core.DTOs;
|
||||||
|
|
||||||
public record ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels);
|
public record ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels);
|
||||||
|
|
||||||
|
public record EncryptionKeyResponse(string Key);
|
||||||
|
|||||||
@@ -6,15 +6,19 @@ namespace EchoHub.Server.Irc;
|
|||||||
public class IrcBroadcaster : IChatBroadcaster
|
public class IrcBroadcaster : IChatBroadcaster
|
||||||
{
|
{
|
||||||
private readonly IrcGatewayService _gateway;
|
private readonly IrcGatewayService _gateway;
|
||||||
|
private readonly IMessageEncryptionService _encryption;
|
||||||
|
|
||||||
public IrcBroadcaster(IrcGatewayService gateway)
|
public IrcBroadcaster(IrcGatewayService gateway, IMessageEncryptionService encryption)
|
||||||
{
|
{
|
||||||
_gateway = gateway;
|
_gateway = gateway;
|
||||||
|
_encryption = encryption;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
||||||
{
|
{
|
||||||
var lines = IrcMessageFormatter.FormatMessage(message);
|
// Decrypt content for IRC clients (they can't handle app-layer encryption)
|
||||||
|
var decryptedMessage = message with { Content = _encryption.Decrypt(message.Content) };
|
||||||
|
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage);
|
||||||
|
|
||||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,19 +23,22 @@ public class ChannelsController : ControllerBase
|
|||||||
private readonly ImageToAsciiService _asciiService;
|
private readonly ImageToAsciiService _asciiService;
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly IChatService _chatService;
|
private readonly IChatService _chatService;
|
||||||
|
private readonly IMessageEncryptionService _encryption;
|
||||||
|
|
||||||
public ChannelsController(
|
public ChannelsController(
|
||||||
EchoHubDbContext db,
|
EchoHubDbContext db,
|
||||||
FileStorageService fileStorage,
|
FileStorageService fileStorage,
|
||||||
ImageToAsciiService asciiService,
|
ImageToAsciiService asciiService,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IChatService chatService)
|
IChatService chatService,
|
||||||
|
IMessageEncryptionService encryption)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
_asciiService = asciiService;
|
_asciiService = asciiService;
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
|
_encryption = encryption;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -221,11 +224,12 @@ public class ChannelsController : ControllerBase
|
|||||||
|
|
||||||
var attachmentUrl = $"/api/files/{fileId}";
|
var attachmentUrl = $"/api/files/{fileId}";
|
||||||
var sender = await _db.Users.FindAsync(userId);
|
var sender = await _db.Users.FindAsync(userId);
|
||||||
|
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
|
||||||
|
|
||||||
var message = new Message
|
var message = new Message
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Content = content,
|
Content = dbContent,
|
||||||
Type = messageType,
|
Type = messageType,
|
||||||
AttachmentUrl = attachmentUrl,
|
AttachmentUrl = attachmentUrl,
|
||||||
AttachmentFileName = file.FileName,
|
AttachmentFileName = file.FileName,
|
||||||
@@ -238,9 +242,10 @@ public class ChannelsController : ControllerBase
|
|||||||
_db.Messages.Add(message);
|
_db.Messages.Add(message);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Encrypt for transport — clients decrypt
|
||||||
var messageDto = new MessageDto(
|
var messageDto = new MessageDto(
|
||||||
message.Id,
|
message.Id,
|
||||||
message.Content,
|
_encryption.Encrypt(content),
|
||||||
message.SenderUsername,
|
message.SenderUsername,
|
||||||
sender?.NicknameColor,
|
sender?.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
@@ -339,11 +344,12 @@ public class ChannelsController : ControllerBase
|
|||||||
|
|
||||||
var attachmentUrl = $"/api/files/{fileId}";
|
var attachmentUrl = $"/api/files/{fileId}";
|
||||||
var sender = await _db.Users.FindAsync(userId);
|
var sender = await _db.Users.FindAsync(userId);
|
||||||
|
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
|
||||||
|
|
||||||
var message = new Message
|
var message = new Message
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Content = content,
|
Content = dbContent,
|
||||||
Type = MessageType.Image,
|
Type = MessageType.Image,
|
||||||
AttachmentUrl = attachmentUrl,
|
AttachmentUrl = attachmentUrl,
|
||||||
AttachmentFileName = fileName,
|
AttachmentFileName = fileName,
|
||||||
@@ -356,9 +362,10 @@ public class ChannelsController : ControllerBase
|
|||||||
_db.Messages.Add(message);
|
_db.Messages.Add(message);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Encrypt for transport — clients decrypt
|
||||||
var messageDto = new MessageDto(
|
var messageDto = new MessageDto(
|
||||||
message.Id,
|
message.Id,
|
||||||
message.Content,
|
_encryption.Encrypt(content),
|
||||||
message.SenderUsername,
|
message.SenderUsername,
|
||||||
sender?.NicknameColor,
|
sender?.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace EchoHub.Server.Controllers;
|
namespace EchoHub.Server.Controllers;
|
||||||
@@ -32,4 +34,17 @@ public class ServerController : ControllerBase
|
|||||||
|
|
||||||
return Ok(status);
|
return Ok(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("encryption-key")]
|
||||||
|
[Authorize]
|
||||||
|
[EnableRateLimiting("auth")]
|
||||||
|
public IActionResult GetEncryptionKey()
|
||||||
|
{
|
||||||
|
var key = _config["Encryption:Key"];
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
return StatusCode(503, new ErrorResponse("Encryption is not configured on this server."));
|
||||||
|
|
||||||
|
return Ok(new EncryptionKeyResponse(key));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ public class EchoHubDbContext : DbContext
|
|||||||
{
|
{
|
||||||
entity.HasKey(m => m.Id);
|
entity.HasKey(m => m.Id);
|
||||||
entity.HasIndex(m => m.SentAt);
|
entity.HasIndex(m => m.SentAt);
|
||||||
entity.Property(m => m.Content).IsRequired().HasMaxLength(2000);
|
entity.Property(m => m.Content).IsRequired().HasMaxLength(16000); // Increased for encrypted content (Base64 overhead)
|
||||||
entity.Property(m => m.SenderUsername).IsRequired().HasMaxLength(50);
|
entity.Property(m => m.SenderUsername).IsRequired().HasMaxLength(50);
|
||||||
entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
|
entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
|
||||||
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
|
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
|
||||||
entity.Property(m => m.EmbedJson).HasMaxLength(8000);
|
entity.Property(m => m.EmbedJson).HasMaxLength(32000); // Increased for encrypted embed JSON
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<ChannelMembership>(entity =>
|
modelBuilder.Entity<ChannelMembership>(entity =>
|
||||||
|
|||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(EchoHubDbContext))]
|
||||||
|
[Migration("20260220133627_AddEncryptionSupport")]
|
||||||
|
partial class AddEncryptionSupport
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("CreatedByUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublic")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Topic")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Channels");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("JoinedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("ChannelMemberships");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentFileName")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AttachmentUrl")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChannelId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(16000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EmbedJson")
|
||||||
|
.HasMaxLength(32000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("SenderUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SenderUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("SentAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ChannelId");
|
||||||
|
|
||||||
|
b.HasIndex("SentAt");
|
||||||
|
|
||||||
|
b.ToTable("Messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("ExpiresAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("RevokedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarAscii")
|
||||||
|
.HasMaxLength(10000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("LastSeenAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long?>("MutedUntil")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("NicknameColor")
|
||||||
|
.HasMaxLength(7)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("StatusMessage")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
|
||||||
|
.WithMany("Messages")
|
||||||
|
.HasForeignKey("ChannelId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Channel");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("EchoHub.Core.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Messages");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEncryptionSupport : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,11 +88,11 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Content")
|
b.Property<string>("Content")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(2000)
|
.HasMaxLength(16000)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("EmbedJson")
|
b.Property<string>("EmbedJson")
|
||||||
.HasMaxLength(8000)
|
.HasMaxLength(32000)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<Guid>("SenderUserId")
|
b.Property<Guid>("SenderUserId")
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ while (true)
|
|||||||
builder.Services.AddSingleton<LinkEmbedService>();
|
builder.Services.AddSingleton<LinkEmbedService>();
|
||||||
builder.Services.AddHostedService<ServerDirectoryService>();
|
builder.Services.AddHostedService<ServerDirectoryService>();
|
||||||
|
|
||||||
|
// ── Encryption ─────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
|
||||||
|
|
||||||
// ── Chat Service + Broadcasters ─────────────────────────────────────
|
// ── Chat Service + Broadcasters ─────────────────────────────────────
|
||||||
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
|
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
|
||||||
builder.Services.AddSingleton<IChatService, ChatService>();
|
builder.Services.AddSingleton<IChatService, ChatService>();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public class ChatService : IChatService
|
|||||||
private readonly PresenceTracker _presenceTracker;
|
private readonly PresenceTracker _presenceTracker;
|
||||||
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||||
private readonly LinkEmbedService _embedService;
|
private readonly LinkEmbedService _embedService;
|
||||||
|
private readonly IMessageEncryptionService _encryption;
|
||||||
private readonly ILogger<ChatService> _logger;
|
private readonly ILogger<ChatService> _logger;
|
||||||
|
|
||||||
public ChatService(
|
public ChatService(
|
||||||
@@ -23,12 +24,14 @@ public class ChatService : IChatService
|
|||||||
PresenceTracker presenceTracker,
|
PresenceTracker presenceTracker,
|
||||||
IEnumerable<IChatBroadcaster> broadcasters,
|
IEnumerable<IChatBroadcaster> broadcasters,
|
||||||
LinkEmbedService embedService,
|
LinkEmbedService embedService,
|
||||||
|
IMessageEncryptionService encryption,
|
||||||
ILogger<ChatService> logger)
|
ILogger<ChatService> logger)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
_presenceTracker = presenceTracker;
|
_presenceTracker = presenceTracker;
|
||||||
_broadcasters = broadcasters;
|
_broadcasters = broadcasters;
|
||||||
_embedService = embedService;
|
_embedService = embedService;
|
||||||
|
_encryption = encryption;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,14 +145,21 @@ public class ChatService : IChatService
|
|||||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||||
return "Invalid channel name.";
|
return "Invalid channel name.";
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
// Decrypt content (client sends encrypted; IRC sends plaintext — Decrypt handles both)
|
||||||
|
var plaintext = _encryption.Decrypt(content);
|
||||||
|
|
||||||
|
// Strip encryption prefix if a user typed it literally (prevents spoofing)
|
||||||
|
while (plaintext.StartsWith("$ENC$"))
|
||||||
|
plaintext = plaintext["$ENC$".Length..];
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(plaintext))
|
||||||
return "Message content cannot be empty.";
|
return "Message content cannot be empty.";
|
||||||
|
|
||||||
if (content.Length > HubConstants.MaxMessageLength)
|
if (plaintext.Length > HubConstants.MaxMessageLength)
|
||||||
return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.";
|
return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.";
|
||||||
|
|
||||||
// Sanitize: collapse excessive newlines
|
// Sanitize on plaintext: collapse excessive newlines
|
||||||
content = SanitizeNewlines(content);
|
plaintext = SanitizeNewlines(plaintext);
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
@@ -175,35 +185,42 @@ public class ChatService : IChatService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to fetch link embeds for URLs in the message
|
// Attempt to fetch link embeds for URLs in the plaintext message
|
||||||
List<EmbedDto>? embeds = null;
|
List<EmbedDto>? embeds = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
embeds = await _embedService.TryGetEmbedsAsync(content);
|
embeds = await _embedService.TryGetEmbedsAsync(plaintext);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to fetch link embeds for message in '{Channel}'", channelName);
|
_logger.LogWarning(ex, "Failed to fetch link embeds for message in '{Channel}'", channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store in DB — encrypted at rest if enabled, plaintext otherwise
|
||||||
|
var embedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null;
|
||||||
|
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(plaintext) : plaintext;
|
||||||
|
var dbEmbedJson = _encryption.EncryptDatabaseEnabled ? _encryption.EncryptNullable(embedJson) : embedJson;
|
||||||
|
|
||||||
var message = new Message
|
var message = new Message
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Content = content,
|
Content = dbContent,
|
||||||
Type = MessageType.Text,
|
Type = MessageType.Text,
|
||||||
SentAt = DateTimeOffset.UtcNow,
|
SentAt = DateTimeOffset.UtcNow,
|
||||||
ChannelId = channel.Id,
|
ChannelId = channel.Id,
|
||||||
SenderUserId = userId,
|
SenderUserId = userId,
|
||||||
SenderUsername = username,
|
SenderUsername = username,
|
||||||
EmbedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null,
|
EmbedJson = dbEmbedJson,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Messages.Add(message);
|
db.Messages.Add(message);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Broadcast encrypted for SignalR clients; IRC broadcaster gets plaintext
|
||||||
|
var encryptedContent = _encryption.Encrypt(plaintext);
|
||||||
var messageDto = new MessageDto(
|
var messageDto = new MessageDto(
|
||||||
message.Id,
|
message.Id,
|
||||||
message.Content,
|
encryptedContent,
|
||||||
message.SenderUsername,
|
message.SenderUsername,
|
||||||
sender?.NicknameColor,
|
sender?.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
@@ -398,7 +415,7 @@ public class ChatService : IChatService
|
|||||||
return string.Join('\n', result);
|
return string.Join('\n', result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
|
private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
|
||||||
{
|
{
|
||||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
@@ -418,16 +435,21 @@ public class ChatService : IChatService
|
|||||||
|
|
||||||
return raw.Select(x =>
|
return raw.Select(x =>
|
||||||
{
|
{
|
||||||
|
// Decrypt DB content (handles both encrypted and plaintext via prefix detection)
|
||||||
|
var plaintext = _encryption.Decrypt(x.m.Content);
|
||||||
|
var embedJsonPlain = _encryption.DecryptNullable(x.m.EmbedJson);
|
||||||
|
|
||||||
List<EmbedDto>? embeds = null;
|
List<EmbedDto>? embeds = null;
|
||||||
if (x.m.EmbedJson is not null)
|
if (embedJsonPlain is not null)
|
||||||
{
|
{
|
||||||
try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(x.m.EmbedJson); }
|
try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(embedJsonPlain); }
|
||||||
catch { /* ignore malformed JSON */ }
|
catch { /* ignore malformed JSON */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Encrypt for transport — client decrypts
|
||||||
return new MessageDto(
|
return new MessageDto(
|
||||||
x.m.Id,
|
x.m.Id,
|
||||||
x.m.Content,
|
_encryption.Encrypt(plaintext),
|
||||||
x.m.SenderUsername,
|
x.m.SenderUsername,
|
||||||
x.NicknameColor,
|
x.NicknameColor,
|
||||||
channelName,
|
channelName,
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using EchoHub.Core.Contracts;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
|
public class MessageEncryptionService : IMessageEncryptionService
|
||||||
|
{
|
||||||
|
private const string EncryptionPrefix = "$ENC$v1$";
|
||||||
|
private const int NonceSizeBytes = 12;
|
||||||
|
private const int TagSizeBytes = 16;
|
||||||
|
|
||||||
|
private readonly byte[] _key;
|
||||||
|
private readonly ILogger<MessageEncryptionService> _logger;
|
||||||
|
|
||||||
|
public bool EncryptDatabaseEnabled { get; }
|
||||||
|
|
||||||
|
public MessageEncryptionService(IConfiguration configuration, ILogger<MessageEncryptionService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
var keyBase64 = configuration["Encryption:Key"]
|
||||||
|
?? throw new InvalidOperationException("Encryption:Key must be configured in appsettings.json.");
|
||||||
|
|
||||||
|
_key = Convert.FromBase64String(keyBase64);
|
||||||
|
|
||||||
|
if (_key.Length != 32)
|
||||||
|
throw new InvalidOperationException($"Encryption:Key must be exactly 32 bytes (256-bit). Got {_key.Length} bytes.");
|
||||||
|
|
||||||
|
EncryptDatabaseEnabled = configuration.GetValue<bool>("Encryption:EncryptDatabase");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Encrypt(string plaintext)
|
||||||
|
{
|
||||||
|
var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
|
||||||
|
var nonce = RandomNumberGenerator.GetBytes(NonceSizeBytes);
|
||||||
|
var ciphertext = new byte[plaintextBytes.Length];
|
||||||
|
var tag = new byte[TagSizeBytes];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(_key, TagSizeBytes);
|
||||||
|
aes.Encrypt(nonce, plaintextBytes, ciphertext, tag);
|
||||||
|
|
||||||
|
// Combine ciphertext + tag for storage
|
||||||
|
var combined = new byte[ciphertext.Length + tag.Length];
|
||||||
|
Buffer.BlockCopy(ciphertext, 0, combined, 0, ciphertext.Length);
|
||||||
|
Buffer.BlockCopy(tag, 0, combined, ciphertext.Length, tag.Length);
|
||||||
|
|
||||||
|
return $"{EncryptionPrefix}{Convert.ToBase64String(nonce)}${Convert.ToBase64String(combined)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Decrypt(string content)
|
||||||
|
{
|
||||||
|
if (!content.StartsWith(EncryptionPrefix))
|
||||||
|
return content; // Legacy plaintext
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = content[EncryptionPrefix.Length..];
|
||||||
|
var separatorIndex = payload.IndexOf('$');
|
||||||
|
if (separatorIndex < 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Malformed encrypted content: missing separator");
|
||||||
|
return "[encrypted message — decryption failed]";
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonceBase64 = payload[..separatorIndex];
|
||||||
|
var combinedBase64 = payload[(separatorIndex + 1)..];
|
||||||
|
|
||||||
|
var nonce = Convert.FromBase64String(nonceBase64);
|
||||||
|
var combined = Convert.FromBase64String(combinedBase64);
|
||||||
|
|
||||||
|
if (combined.Length < TagSizeBytes)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Malformed encrypted content: data too short");
|
||||||
|
return "[encrypted message — decryption failed]";
|
||||||
|
}
|
||||||
|
|
||||||
|
var ciphertextLength = combined.Length - TagSizeBytes;
|
||||||
|
var ciphertext = combined.AsSpan(0, ciphertextLength);
|
||||||
|
var tag = combined.AsSpan(ciphertextLength, TagSizeBytes);
|
||||||
|
var plaintext = new byte[ciphertextLength];
|
||||||
|
|
||||||
|
using var aes = new AesGcm(_key, TagSizeBytes);
|
||||||
|
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||||
|
|
||||||
|
return Encoding.UTF8.GetString(plaintext);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to decrypt message content");
|
||||||
|
return "[encrypted message — decryption failed]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? EncryptNullable(string? value)
|
||||||
|
=> value is null ? null : Encrypt(value);
|
||||||
|
|
||||||
|
public string? DecryptNullable(string? value)
|
||||||
|
=> value is null ? null : Decrypt(value);
|
||||||
|
}
|
||||||
@@ -136,4 +136,5 @@ public static partial class DataMigrationService
|
|||||||
logger.LogInformation("Migrated {Count} embed records from single-object to array format.", modified);
|
logger.LogInformation("Migrated {Count} embed records from single-object to array format.", modified);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public static class FirstRunSetup
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
EnsureJwtSecret(settingsPath);
|
EnsureJwtSecret(settingsPath);
|
||||||
|
EnsureEncryptionKey(settingsPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void EnsureJwtSecret(string settingsPath)
|
private static void EnsureJwtSecret(string settingsPath)
|
||||||
@@ -45,4 +46,27 @@ public static class FirstRunSetup
|
|||||||
File.WriteAllText(settingsPath, root.ToJsonString(writeOptions));
|
File.WriteAllText(settingsPath, root.ToJsonString(writeOptions));
|
||||||
Console.WriteLine("Generated new JWT secret in appsettings.json.");
|
Console.WriteLine("Generated new JWT secret in appsettings.json.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void EnsureEncryptionKey(string settingsPath)
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(settingsPath);
|
||||||
|
var root = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip });
|
||||||
|
if (root is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var currentKey = root["Encryption"]?["Key"]?.GetValue<string>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(currentKey))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Generate a 256-bit (32-byte) AES key
|
||||||
|
var key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
root["Encryption"] ??= new JsonObject();
|
||||||
|
root["Encryption"]!["Key"] = key;
|
||||||
|
|
||||||
|
var writeOptions = new JsonSerializerOptions { WriteIndented = true };
|
||||||
|
File.WriteAllText(settingsPath, root.ToJsonString(writeOptions));
|
||||||
|
Console.WriteLine("Generated new encryption key in appsettings.json.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
"PublicServer": false,
|
"PublicServer": false,
|
||||||
"PublicHost": ""
|
"PublicHost": ""
|
||||||
},
|
},
|
||||||
|
"Encryption": {
|
||||||
|
"Key": "",
|
||||||
|
"EncryptDatabase": false
|
||||||
|
},
|
||||||
"Irc": {
|
"Irc": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"Port": 6667,
|
"Port": 6667,
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using EchoHub.Client.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
public class ClientEncryptionServiceTests
|
||||||
|
{
|
||||||
|
private static string GenerateKey() =>
|
||||||
|
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
private static ClientEncryptionService CreateInitialized(string? key = null)
|
||||||
|
{
|
||||||
|
var service = new ClientEncryptionService();
|
||||||
|
service.SetKey(key ?? GenerateKey());
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initialization ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsInitialized_DefaultFalse()
|
||||||
|
{
|
||||||
|
var service = new ClientEncryptionService();
|
||||||
|
Assert.False(service.IsInitialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsInitialized_TrueAfterSetKey()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
Assert.True(service.IsInitialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EncryptDatabaseEnabled_AlwaysFalse()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
Assert.False(service.EncryptDatabaseEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetKey_WrongLength_Throws()
|
||||||
|
{
|
||||||
|
var service = new ClientEncryptionService();
|
||||||
|
var shortKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => service.SetKey(shortKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetKey_InvalidBase64_Throws()
|
||||||
|
{
|
||||||
|
var service = new ClientEncryptionService();
|
||||||
|
Assert.Throws<FormatException>(() => service.SetKey("not-valid-base64!!!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Encrypt before initialization ────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encrypt_NotInitialized_PassesThrough()
|
||||||
|
{
|
||||||
|
var service = new ClientEncryptionService();
|
||||||
|
var result = service.Encrypt("hello");
|
||||||
|
|
||||||
|
Assert.Equal("hello", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_NotInitialized_PassesThrough()
|
||||||
|
{
|
||||||
|
var service = new ClientEncryptionService();
|
||||||
|
var result = service.Decrypt("$ENC$v1$something$else");
|
||||||
|
|
||||||
|
Assert.Equal("$ENC$v1$something$else", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Encrypt after initialization ─────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encrypt_ProducesEncryptedFormat()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
var encrypted = service.Encrypt("Hello, world!");
|
||||||
|
|
||||||
|
Assert.StartsWith("$ENC$v1$", encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encrypt_DifferentNonceEachTime()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
var a = service.Encrypt("same message");
|
||||||
|
var b = service.Encrypt("same message");
|
||||||
|
|
||||||
|
Assert.NotEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encrypt_EmptyString_Works()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
var encrypted = service.Encrypt("");
|
||||||
|
|
||||||
|
Assert.StartsWith("$ENC$v1$", encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decrypt ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_RoundTrip()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
var original = "Hello, world!";
|
||||||
|
|
||||||
|
var encrypted = service.Encrypt(original);
|
||||||
|
var decrypted = service.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Equal(original, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_EmptyString_RoundTrip()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
var encrypted = service.Encrypt("");
|
||||||
|
var decrypted = service.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Equal("", decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_Unicode_RoundTrip()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
var original = "Hello 🌍 世界 مرحبا";
|
||||||
|
|
||||||
|
var encrypted = service.Encrypt(original);
|
||||||
|
var decrypted = service.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Equal(original, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_PlaintextPassthrough()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
var plaintext = "This is just plain text";
|
||||||
|
|
||||||
|
var result = service.Decrypt(plaintext);
|
||||||
|
|
||||||
|
Assert.Equal(plaintext, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_WrongKey_ReturnsFailureMessage()
|
||||||
|
{
|
||||||
|
var encryptor = CreateInitialized();
|
||||||
|
var decryptor = CreateInitialized(); // different key
|
||||||
|
|
||||||
|
var encrypted = encryptor.Encrypt("secret message");
|
||||||
|
var result = decryptor.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Contains("decryption failed", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_MalformedContent_ReturnsOriginal()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
|
||||||
|
// Malformed: prefix present but no valid separator after nonce
|
||||||
|
var result = service.Decrypt("$ENC$v1$noseperatorhere");
|
||||||
|
|
||||||
|
Assert.Equal("$ENC$v1$noseperatorhere", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_TamperedCiphertext_ReturnsFailure()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
var encrypted = service.Encrypt("original message");
|
||||||
|
|
||||||
|
var tampered = encrypted[..^5] + "XXXXX";
|
||||||
|
var result = service.Decrypt(tampered);
|
||||||
|
|
||||||
|
Assert.Contains("decryption failed", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nullable helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EncryptNullable_Null_ReturnsNull()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
Assert.Null(service.EncryptNullable(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DecryptNullable_Null_ReturnsNull()
|
||||||
|
{
|
||||||
|
var service = CreateInitialized();
|
||||||
|
Assert.Null(service.DecryptNullable(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key replacement ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetKey_CanBeCalledMultipleTimes()
|
||||||
|
{
|
||||||
|
var service = new ClientEncryptionService();
|
||||||
|
|
||||||
|
var key1 = GenerateKey();
|
||||||
|
var key2 = GenerateKey();
|
||||||
|
|
||||||
|
service.SetKey(key1);
|
||||||
|
var encrypted1 = service.Encrypt("test");
|
||||||
|
|
||||||
|
service.SetKey(key2);
|
||||||
|
var encrypted2 = service.Encrypt("test");
|
||||||
|
|
||||||
|
// Both produce encrypted content
|
||||||
|
Assert.StartsWith("$ENC$v1$", encrypted1);
|
||||||
|
Assert.StartsWith("$ENC$v1$", encrypted2);
|
||||||
|
|
||||||
|
// Old key's content can't be decrypted with new key
|
||||||
|
var result = service.Decrypt(encrypted1);
|
||||||
|
Assert.Contains("decryption failed", result);
|
||||||
|
|
||||||
|
// New key's content decrypts fine
|
||||||
|
Assert.Equal("test", service.Decrypt(encrypted2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EchoHub.Client\EchoHub.Client.csproj" />
|
||||||
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
||||||
<ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" />
|
<ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using EchoHub.Client.Services;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests that server and client encryption services are fully interoperable —
|
||||||
|
/// content encrypted by one can be decrypted by the other using the same key.
|
||||||
|
/// </summary>
|
||||||
|
public class EncryptionCompatibilityTests
|
||||||
|
{
|
||||||
|
private static readonly string SharedKey =
|
||||||
|
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
private static MessageEncryptionService CreateServer(string? key = null)
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Encryption:Key"] = key ?? SharedKey,
|
||||||
|
["Encryption:EncryptDatabase"] = "false",
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var logger = NullLoggerFactory.Instance.CreateLogger<MessageEncryptionService>();
|
||||||
|
return new MessageEncryptionService(config, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClientEncryptionService CreateClient(string? key = null)
|
||||||
|
{
|
||||||
|
var service = new ClientEncryptionService();
|
||||||
|
service.SetKey(key ?? SharedKey);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cross-service round trips ────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClientEncrypt_ServerDecrypt()
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
var server = CreateServer();
|
||||||
|
var original = "Hello from client!";
|
||||||
|
|
||||||
|
var encrypted = client.Encrypt(original);
|
||||||
|
var decrypted = server.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Equal(original, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ServerEncrypt_ClientDecrypt()
|
||||||
|
{
|
||||||
|
var server = CreateServer();
|
||||||
|
var client = CreateClient();
|
||||||
|
var original = "Hello from server!";
|
||||||
|
|
||||||
|
var encrypted = server.Encrypt(original);
|
||||||
|
var decrypted = client.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Equal(original, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("a")]
|
||||||
|
[InlineData("Hello, world!")]
|
||||||
|
[InlineData("Hello 🌍 世界 مرحبا")]
|
||||||
|
[InlineData("Line1\nLine2\nLine3")]
|
||||||
|
public void CrossDecrypt_VariousMessages(string message)
|
||||||
|
{
|
||||||
|
var server = CreateServer();
|
||||||
|
var client = CreateClient();
|
||||||
|
|
||||||
|
// Client → Server
|
||||||
|
var clientEncrypted = client.Encrypt(message);
|
||||||
|
Assert.Equal(message, server.Decrypt(clientEncrypted));
|
||||||
|
|
||||||
|
// Server → Client
|
||||||
|
var serverEncrypted = server.Encrypt(message);
|
||||||
|
Assert.Equal(message, client.Decrypt(serverEncrypted));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CrossDecrypt_LargeMessage()
|
||||||
|
{
|
||||||
|
var server = CreateServer();
|
||||||
|
var client = CreateClient();
|
||||||
|
var original = new string('X', 10000);
|
||||||
|
|
||||||
|
var encrypted = client.Encrypt(original);
|
||||||
|
Assert.Equal(original, server.Decrypt(encrypted));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key mismatch ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentKeys_ClientEncrypt_ServerCantDecrypt()
|
||||||
|
{
|
||||||
|
var clientKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
var serverKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
var client = CreateClient(clientKey);
|
||||||
|
var server = CreateServer(serverKey);
|
||||||
|
|
||||||
|
var encrypted = client.Encrypt("secret");
|
||||||
|
var result = server.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Contains("decryption failed", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentKeys_ServerEncrypt_ClientCantDecrypt()
|
||||||
|
{
|
||||||
|
var clientKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
var serverKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
var server = CreateServer(serverKey);
|
||||||
|
var client = CreateClient(clientKey);
|
||||||
|
|
||||||
|
var encrypted = server.Encrypt("secret");
|
||||||
|
var result = client.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Contains("decryption failed", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plaintext passthrough ────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Server_DecryptsPlaintext_AsPassthrough()
|
||||||
|
{
|
||||||
|
var server = CreateServer();
|
||||||
|
Assert.Equal("plain text", server.Decrypt("plain text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Client_DecryptsPlaintext_AsPassthrough()
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
Assert.Equal("plain text", client.Decrypt("plain text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full E2E flow simulation ─────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullFlow_ClientSend_ServerProcess_BroadcastBack()
|
||||||
|
{
|
||||||
|
var client = CreateClient();
|
||||||
|
var server = CreateServer();
|
||||||
|
|
||||||
|
// 1. Client encrypts and sends
|
||||||
|
var originalMessage = "Hello everyone!";
|
||||||
|
var clientEncrypted = client.Encrypt(originalMessage);
|
||||||
|
Assert.StartsWith("$ENC$v1$", clientEncrypted);
|
||||||
|
|
||||||
|
// 2. Server decrypts for processing
|
||||||
|
var serverPlaintext = server.Decrypt(clientEncrypted);
|
||||||
|
Assert.Equal(originalMessage, serverPlaintext);
|
||||||
|
|
||||||
|
// 3. Server re-encrypts for broadcast (different nonce)
|
||||||
|
var serverEncrypted = server.Encrypt(serverPlaintext);
|
||||||
|
Assert.StartsWith("$ENC$v1$", serverEncrypted);
|
||||||
|
Assert.NotEqual(clientEncrypted, serverEncrypted); // different nonce
|
||||||
|
|
||||||
|
// 4. Receiving client decrypts the broadcast
|
||||||
|
var receivedPlaintext = client.Decrypt(serverEncrypted);
|
||||||
|
Assert.Equal(originalMessage, receivedPlaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullFlow_ServerGeneratedMessage_EncryptForBroadcast()
|
||||||
|
{
|
||||||
|
var server = CreateServer();
|
||||||
|
var client = CreateClient();
|
||||||
|
|
||||||
|
// Server generates a system message (e.g. file upload notification)
|
||||||
|
var systemMessage = "user uploaded file.png";
|
||||||
|
|
||||||
|
// Server encrypts for broadcast
|
||||||
|
var encrypted = server.Encrypt(systemMessage);
|
||||||
|
|
||||||
|
// Client decrypts
|
||||||
|
var decrypted = client.Decrypt(encrypted);
|
||||||
|
Assert.Equal(systemMessage, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullFlow_HistoryRetrieve_ServerEncrypts_ClientDecrypts()
|
||||||
|
{
|
||||||
|
var server = CreateServer();
|
||||||
|
var client = CreateClient();
|
||||||
|
|
||||||
|
// Simulate loading N messages from DB (plaintext) and encrypting for transport
|
||||||
|
var messages = new[] { "msg1", "Hello 🌍", "msg with\nnewline" };
|
||||||
|
|
||||||
|
foreach (var original in messages)
|
||||||
|
{
|
||||||
|
var encrypted = server.Encrypt(original);
|
||||||
|
var decrypted = client.Decrypt(encrypted);
|
||||||
|
Assert.Equal(original, decrypted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
public class MessageEncryptionServiceTests
|
||||||
|
{
|
||||||
|
private static MessageEncryptionService CreateService(
|
||||||
|
string? key = null, bool encryptDatabase = false)
|
||||||
|
{
|
||||||
|
key ??= Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Encryption:Key"] = key,
|
||||||
|
["Encryption:EncryptDatabase"] = encryptDatabase.ToString(),
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var logger = NullLoggerFactory.Instance.CreateLogger<MessageEncryptionService>();
|
||||||
|
return new MessageEncryptionService(config, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Constructor validation ───────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_MissingKey_Throws()
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var logger = NullLoggerFactory.Instance.CreateLogger<MessageEncryptionService>();
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
new MessageEncryptionService(config, logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_WrongKeyLength_Throws()
|
||||||
|
{
|
||||||
|
var shortKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => CreateService(shortKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_ValidKey_Succeeds()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
Assert.NotNull(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EncryptDatabaseEnabled ───────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EncryptDatabaseEnabled_DefaultFalse()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
Assert.False(service.EncryptDatabaseEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EncryptDatabaseEnabled_WhenConfiguredTrue()
|
||||||
|
{
|
||||||
|
var service = CreateService(encryptDatabase: true);
|
||||||
|
Assert.True(service.EncryptDatabaseEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Encrypt ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encrypt_ProducesEncryptedFormat()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var encrypted = service.Encrypt("Hello, world!");
|
||||||
|
|
||||||
|
Assert.StartsWith("$ENC$v1$", encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encrypt_DifferentNonceEachTime()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var a = service.Encrypt("same message");
|
||||||
|
var b = service.Encrypt("same message");
|
||||||
|
|
||||||
|
Assert.NotEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encrypt_EmptyString_Works()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var encrypted = service.Encrypt("");
|
||||||
|
|
||||||
|
Assert.StartsWith("$ENC$v1$", encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Encrypt_Unicode_Works()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var encrypted = service.Encrypt("Hello 🌍 世界 مرحبا");
|
||||||
|
|
||||||
|
Assert.StartsWith("$ENC$v1$", encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decrypt ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_RoundTrip()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var original = "Hello, world!";
|
||||||
|
|
||||||
|
var encrypted = service.Encrypt(original);
|
||||||
|
var decrypted = service.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Equal(original, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_EmptyString_RoundTrip()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var encrypted = service.Encrypt("");
|
||||||
|
var decrypted = service.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Equal("", decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_Unicode_RoundTrip()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var original = "Hello 🌍 世界 مرحبا";
|
||||||
|
|
||||||
|
var encrypted = service.Encrypt(original);
|
||||||
|
var decrypted = service.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Equal(original, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_LongMessage_RoundTrip()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var original = new string('A', 5000);
|
||||||
|
|
||||||
|
var encrypted = service.Encrypt(original);
|
||||||
|
var decrypted = service.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Equal(original, decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_PlaintextPassthrough()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var plaintext = "This is just plain text";
|
||||||
|
|
||||||
|
var result = service.Decrypt(plaintext);
|
||||||
|
|
||||||
|
Assert.Equal(plaintext, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_WrongKey_ReturnsFailureMessage()
|
||||||
|
{
|
||||||
|
var key1 = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
var key2 = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
var encryptor = CreateService(key1);
|
||||||
|
var decryptor = CreateService(key2);
|
||||||
|
|
||||||
|
var encrypted = encryptor.Encrypt("secret message");
|
||||||
|
var result = decryptor.Decrypt(encrypted);
|
||||||
|
|
||||||
|
Assert.Contains("decryption failed", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_MalformedContent_MissingSeparator_ReturnsFailure()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var malformed = "$ENC$v1$noseperatorhere";
|
||||||
|
|
||||||
|
var result = service.Decrypt(malformed);
|
||||||
|
|
||||||
|
Assert.Contains("decryption failed", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_MalformedContent_TooShort_ReturnsFailure()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var malformed = "$ENC$v1$AAAA$BBBB";
|
||||||
|
|
||||||
|
var result = service.Decrypt(malformed);
|
||||||
|
|
||||||
|
Assert.Contains("decryption failed", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decrypt_TamperedCiphertext_ReturnsFailure()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var encrypted = service.Encrypt("original message");
|
||||||
|
|
||||||
|
// Tamper with the ciphertext by flipping a character
|
||||||
|
var tampered = encrypted[..^5] + "XXXXX";
|
||||||
|
var result = service.Decrypt(tampered);
|
||||||
|
|
||||||
|
Assert.Contains("decryption failed", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nullable helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EncryptNullable_Null_ReturnsNull()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
Assert.Null(service.EncryptNullable(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EncryptNullable_Value_Encrypts()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var result = service.EncryptNullable("test");
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.StartsWith("$ENC$v1$", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DecryptNullable_Null_ReturnsNull()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
Assert.Null(service.DecryptNullable(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DecryptNullable_EncryptedValue_Decrypts()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var encrypted = service.Encrypt("test");
|
||||||
|
|
||||||
|
var result = service.DecryptNullable(encrypted);
|
||||||
|
|
||||||
|
Assert.Equal("test", result);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user