Add TcpRpc server and client and split crypto code into multiple classes

This commit is contained in:
Stone_Red
2026-06-09 19:26:34 +02:00
parent 5b8936dd16
commit e6bab7e371
11 changed files with 473 additions and 109 deletions
-108
View File
@@ -1,108 +0,0 @@
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Digests;
using Org.BouncyCastle.Crypto.Engines;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Modes;
using Org.BouncyCastle.Crypto.Parameters;
using System.Security.Cryptography;
using System.Text;
public static class BcAesCrypto
{
private const int KeySize = 32; // 256-bit AES
private const int SaltSize = 16; // 128-bit salt
private const int NonceSize = 12; // GCM standard
private const int TagSize = 128; // authentication tag (bits)
private const int Iterations = 150_000; // PBKDF2 cost factor
// -----------------------------
// PBKDF2 Key Derivation
// -----------------------------
private static byte[] DeriveKey(string password, byte[] salt)
{
Pkcs5S2ParametersGenerator generator = new(new Sha256Digest());
generator.Init(
PbeParametersGenerator.Pkcs5PasswordToBytes(password.ToCharArray()),
salt,
Iterations
);
KeyParameter keyParam = (KeyParameter)generator.GenerateDerivedMacParameters(KeySize * 8);
return keyParam.GetKey();
}
// -----------------------------
// Encrypt
// -----------------------------
public static string Encrypt(string plainText, string password)
{
byte[] salt = new byte[SaltSize];
RandomNumberGenerator.Fill(salt);
byte[] key = DeriveKey(password, salt);
byte[] nonce = new byte[NonceSize];
RandomNumberGenerator.Fill(nonce);
GcmBlockCipher cipher = new(new AesEngine());
cipher.Init(true, new AeadParameters(
new KeyParameter(key),
TagSize,
nonce
));
byte[] input = Encoding.UTF8.GetBytes(plainText);
byte[] output = new byte[cipher.GetOutputSize(input.Length)];
int len = cipher.ProcessBytes(input, 0, input.Length, output, 0);
_ = cipher.DoFinal(output, len);
// Combine: salt + nonce + ciphertext
byte[] result = new byte[salt.Length + nonce.Length + output.Length];
Buffer.BlockCopy(salt, 0, result, 0, salt.Length);
Buffer.BlockCopy(nonce, 0, result, salt.Length, nonce.Length);
Buffer.BlockCopy(output, 0, result, salt.Length + nonce.Length, output.Length);
return Convert.ToBase64String(result);
}
// -----------------------------
// Decrypt
// -----------------------------
public static string Decrypt(string cipherText, string password)
{
byte[] data = Convert.FromBase64String(cipherText);
byte[] salt = new byte[SaltSize];
byte[] nonce = new byte[NonceSize];
Buffer.BlockCopy(data, 0, salt, 0, SaltSize);
Buffer.BlockCopy(data, SaltSize, nonce, 0, NonceSize);
int cipherLength = data.Length - SaltSize - NonceSize;
byte[] cipherBytes = new byte[cipherLength];
Buffer.BlockCopy(data, SaltSize + NonceSize, cipherBytes, 0, cipherLength);
byte[] key = DeriveKey(password, salt);
GcmBlockCipher cipher = new(new AesEngine());
cipher.Init(false, new AeadParameters(
new KeyParameter(key),
TagSize,
nonce
));
byte[] plain = new byte[cipher.GetOutputSize(cipherBytes.Length)];
int len = cipher.ProcessBytes(cipherBytes, 0, cipherBytes.Length, plain, 0);
_ = cipher.DoFinal(plain, len);
return Encoding.UTF8.GetString(plain);
}
}
+16
View File
@@ -0,0 +1,16 @@
using RemSox.Utils;
namespace RemSox.Networking;
internal class AesPacketCrypto(byte[] key) : IPacketCrypto
{
public byte[] Decrypt(byte[] data)
{
return AesGcmCrypto.Decrypt(data, key);
}
public byte[] Encrypt(byte[] data)
{
return AesGcmCrypto.Encrypt(data, key);
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace RemSox.Networking;
public interface IPacketCrypto
{
byte[] Encrypt(byte[] data);
byte[] Decrypt(byte[] data);
}
+18
View File
@@ -0,0 +1,18 @@
using System.Net.Sockets;
/// <summary>
/// Simple wrapper around TcpClient to hold connection-scoped resources safely.
/// </summary>
public class TcpConnection(TcpClient client) : IDisposable
{
public TcpClient Client { get; } = client;
public NetworkStream Stream { get; } = client.GetStream();
public SemaphoreSlim SendLock { get; } = new(1, 1);
public void Dispose()
{
Stream.Dispose();
Client.Dispose();
SendLock.Dispose();
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace RemSox.Networking;
public class TcpMessage
{
public string Type { get; set; } = string.Empty;
public string RequestId { get; set; } = string.Empty;
public byte[] Payload { get; set; } = [];
}
+141
View File
@@ -0,0 +1,141 @@
using RemSox.Networking;
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Net.Sockets;
using System.Text.Json;
public abstract class TcpRpcBase(IPacketCrypto? crypto = null)
{
protected readonly IPacketCrypto? crypto = crypto;
protected readonly Dictionary<string, Func<TcpConnection, TcpMessage, Task>> handlers = [];
protected readonly ConcurrentDictionary<string, TaskCompletionSource<byte[]>> pendingRequests = new();
// Security Guard: Prevent OOM/DoS via oversized length headers (Default: 32MB)
protected const int MaxMessageSize = 32 * 1024 * 1024;
/// <summary>
/// Base message registration for handlers tracking the sending connection.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Handled via library constraints")]
public void ListenTo<T>(string type, Func<TcpConnection, T, Task> handler)
{
handlers[type] = async (conn, msg) =>
{
T data = JsonSerializer.Deserialize<T>(msg.Payload)!;
await handler(conn, data);
};
}
/// <summary>
/// Overloaded listener where connection mapping can be ignored (Convenient for Clients).
/// </summary>
public void ListenTo<T>(string type, Func<T, Task> handler)
{
ListenTo<T>(type, async (_, data) => await handler(data));
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Handled via library constraints")]
protected async Task SendRaw(TcpConnection conn, TcpMessage msg)
{
byte[] data = JsonSerializer.SerializeToUtf8Bytes(msg);
if (crypto is not null)
{
data = crypto.Encrypt(data);
}
// Allocate a single contiguous frame buffer to prevent inter-thread fragmentation
// and eliminate redundant Socket Write Syscalls.
byte[] packet = new byte[4 + data.Length];
BinaryPrimitives.WriteInt32LittleEndian(packet, data.Length);
Array.Copy(data, 0, packet, 4, data.Length);
// Enforce sequence safety across multiple threads pushing data out of a singular stream
await conn.SendLock.WaitAsync();
try
{
await conn.Stream.WriteAsync(packet);
}
finally
{
_ = conn.SendLock.Release();
}
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Handled via library constraints")]
protected async Task HandleConnection(TcpConnection conn, CancellationToken token = default)
{
NetworkStream stream = conn.Stream;
byte[] lengthBytes = new byte[4];
try
{
while (!token.IsCancellationRequested && conn.Client.Connected)
{
// 1. Frame Length Read
await stream.ReadExactlyAsync(lengthBytes, token);
int length = BinaryPrimitives.ReadInt32LittleEndian(lengthBytes);
// DoS Payload Protection Check
if (length is <= 0 or > MaxMessageSize)
{
throw new InvalidDataException($"Protocol violation: Received packet length of {length} bytes exceeds limits.");
}
// 2. Body Payload Read
byte[] buffer = new byte[length];
await stream.ReadExactlyAsync(buffer, token);
if (crypto is not null)
{
buffer = crypto.Decrypt(buffer);
}
TcpMessage? msg = JsonSerializer.Deserialize<TcpMessage>(buffer);
if (msg is null)
{
continue;
}
// 3. Response Handler Check
if (pendingRequests.TryGetValue(msg.RequestId, out TaskCompletionSource<byte[]>? tcs))
{
_ = tcs.TrySetResult(msg.Payload);
_ = pendingRequests.TryRemove(msg.RequestId, out _);
continue;
}
// 4. Inbound Router Handler
if (handlers.TryGetValue(msg.Type, out Func<TcpConnection, TcpMessage, Task>? handler))
{
// Decouple handler execution to the ThreadPool so slow business logic
// doesn't bottleneck packet processing from the network stream interface.
_ = Task.Run(async () =>
{
try
{
await handler(conn, msg);
}
catch
{
// Operational log placement here for user exceptions inside delegates
}
}, token);
}
}
}
catch
{
// Explicitly handles natural dropping scenarios cleanly
}
finally
{
OnConnectionClosed(conn);
conn.Dispose();
}
}
protected abstract void OnConnectionClosed(TcpConnection conn);
}
+92
View File
@@ -0,0 +1,92 @@
using System.Diagnostics.CodeAnalysis;
using System.Net.Sockets;
using System.Text.Json;
namespace RemSox.Networking;
public class TcpRpcClient(IPacketCrypto? crypto = null) : TcpRpcBase(crypto)
{
private TcpConnection? connection;
public async Task ConnectAsync(string host, int port)
{
TcpClient client = new();
await client.ConnectAsync(host, port);
connection = new TcpConnection(client);
// Run network monitoring task loop background-detached
_ = HandleConnection(connection);
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Handled via library constraints")]
public async Task<TRes> RequestAsync<TReq, TRes>(string type, TReq request, TimeSpan timeout = default)
{
if (connection is null)
{
throw new InvalidOperationException("Client not connected.");
}
if (timeout == default)
{
timeout = TimeSpan.FromSeconds(30); // Default fallback timeout
}
string requestId = Guid.NewGuid().ToString();
TaskCompletionSource<byte[]> tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
pendingRequests[requestId] = tcs;
try
{
await SendRaw(connection, new TcpMessage
{
Type = type,
RequestId = requestId,
Payload = JsonSerializer.SerializeToUtf8Bytes(request)
});
// Enforce async timeout safety to prevent permanent dictionary leaks on dropped calls
using CancellationTokenSource timeoutCts = new(timeout);
await using (timeoutCts.Token.Register(() => tcs.TrySetCanceled()))
{
byte[] responseBytes = await tcs.Task;
return JsonSerializer.Deserialize<TRes>(responseBytes)!;
}
}
finally
{
_ = pendingRequests.TryRemove(requestId, out _);
}
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Handled via library constraints")]
public Task SendAsync<T>(string type, T data)
{
if (connection is null)
{
throw new InvalidOperationException("Client not connected.");
}
return SendRaw(connection, new TcpMessage
{
Type = type,
RequestId = Guid.NewGuid().ToString(),
Payload = JsonSerializer.SerializeToUtf8Bytes(data)
});
}
protected override void OnConnectionClosed(TcpConnection conn)
{
if (connection == conn)
{
connection = null;
}
// Fail-fast all lingering tasks waiting on an dead connection loop
foreach (TaskCompletionSource<byte[]> req in pendingRequests.Values)
{
_ = req.TrySetException(new SocketException((int)SocketError.ConnectionReset));
}
pendingRequests.Clear();
}
}
+94
View File
@@ -0,0 +1,94 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
namespace RemSox.Networking;
public class TcpRpcServer(IPacketCrypto? crypto = null) : TcpRpcBase(crypto)
{
// Fix: Using ConcurrentDictionary to prevent collection errors during structural additions/prunings
private readonly ConcurrentDictionary<TcpConnection, byte> connections = new();
private TcpListener? listener;
private CancellationTokenSource? cts;
public async Task StartAsync(int port, CancellationToken token = default)
{
cts = CancellationTokenSource.CreateLinkedTokenSource(token);
listener = new TcpListener(IPAddress.Any, port);
listener.Start();
try
{
while (!cts.IsCancellationRequested)
{
TcpClient client = await listener.AcceptTcpClientAsync(cts.Token);
TcpConnection conn = new(client);
_ = connections.TryAdd(conn, 0);
_ = HandleConnection(conn, cts.Token);
}
}
catch (OperationCanceledException)
{
// Expected shutdown scenario
}
}
public void Stop()
{
cts?.Cancel();
listener?.Stop();
foreach (TcpConnection c in connections.Keys)
{
c.Dispose();
}
connections.Clear();
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Handled via library constraints")]
public void RespondTo<TReq, TRes>(string type, Func<TReq, Task<TRes>> handler)
{
handlers[type] = async (conn, msg) =>
{
TReq req = JsonSerializer.Deserialize<TReq>(msg.Payload)!;
TRes res = await handler(req);
await SendRaw(conn, new TcpMessage
{
Type = type,
RequestId = msg.RequestId,
Payload = JsonSerializer.SerializeToUtf8Bytes(res)
});
};
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Handled via library constraints")]
public Task SendToAll<T>(string type, T data)
{
byte[] payloadBytes = JsonSerializer.SerializeToUtf8Bytes(data);
List<Task> sendTasks = [];
foreach (TcpConnection conn in connections.Keys)
{
sendTasks.Add(SendRaw(conn, new TcpMessage
{
Type = type,
RequestId = Guid.NewGuid().ToString(),
Payload = payloadBytes
}));
}
return Task.WhenAll(sendTasks);
}
protected override void OnConnectionClosed(TcpConnection conn)
{
_ = connections.TryRemove(conn, out _);
}
}
+1 -1
View File
@@ -68,7 +68,7 @@ public class DesktopProcess : Process
WindowManager.Update(); WindowManager.Update();
// Sleep slightly to yield CPU to the main CLI thread (approx 60 FPS) // Sleep slightly to yield CPU to the main CLI thread (approx 60 FPS)
//Thread.Sleep(16); Thread.Sleep(16);
} }
IsRunning = false; IsRunning = false;
+69
View File
@@ -0,0 +1,69 @@
using Org.BouncyCastle.Crypto.Engines;
using Org.BouncyCastle.Crypto.Modes;
using Org.BouncyCastle.Crypto.Parameters;
using System.Security.Cryptography;
using System.Text;
namespace RemSox.Utils;
public static class AesGcmCrypto
{
private const int NonceSize = 12;
private const int TagSize = 128;
public static byte[] Encrypt(byte[] plainData, byte[] key)
{
byte[] nonce = new byte[NonceSize];
RandomNumberGenerator.Fill(nonce);
GcmBlockCipher cipher = new GcmBlockCipher(new AesEngine());
cipher.Init(true, new AeadParameters(new KeyParameter(key), TagSize, nonce));
byte[] output = new byte[cipher.GetOutputSize(plainData.Length)];
int len = cipher.ProcessBytes(plainData, 0, plainData.Length, output, 0);
_ = cipher.DoFinal(output, len);
byte[] result = new byte[nonce.Length + output.Length];
Buffer.BlockCopy(nonce, 0, result, 0, nonce.Length);
Buffer.BlockCopy(output, 0, result, nonce.Length, output.Length);
return result;
}
public static string Encrypt(string text, byte[] key)
{
byte[] data = Encoding.UTF8.GetBytes(text);
return Convert.ToBase64String(Encrypt(data, key));
}
public static byte[] Decrypt(byte[] encryptedData, byte[] key)
{
byte[] nonce = new byte[NonceSize];
Buffer.BlockCopy(encryptedData, 0, nonce, 0, NonceSize);
int cipherLength = encryptedData.Length - NonceSize;
byte[] cipherBytes = new byte[cipherLength];
Buffer.BlockCopy(encryptedData, NonceSize, cipherBytes, 0, cipherLength);
GcmBlockCipher cipher = new GcmBlockCipher(new AesEngine());
cipher.Init(false, new AeadParameters(new KeyParameter(key), TagSize, nonce));
byte[] plain = new byte[cipher.GetOutputSize(cipherBytes.Length)];
int len = cipher.ProcessBytes(cipherBytes, 0, cipherBytes.Length, plain, 0);
_ = cipher.DoFinal(plain, len);
return plain;
}
public static string Decrypt(string cipherText, byte[] key)
{
byte[] data = Convert.FromBase64String(cipherText);
byte[] plain = Decrypt(data, key);
return Encoding.UTF8.GetString(plain);
}
}
+26
View File
@@ -0,0 +1,26 @@
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Digests;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;
namespace RemSox.Utils;
public static class PasswordKeyDeriver
{
private const int KeySize = 32; // 256-bit key
private const int Iterations = 150_000; // PBKDF2 cost factor
public static byte[] DeriveKey(string password, byte[] salt)
{
Pkcs5S2ParametersGenerator generator = new Pkcs5S2ParametersGenerator(new Sha256Digest());
generator.Init(
PbeParametersGenerator.Pkcs5PasswordToBytes(password.ToCharArray()),
salt,
Iterations
);
return ((KeyParameter)generator.GenerateDerivedMacParameters(KeySize * 8))
.GetKey();
}
}