From 8a771220e4f3c08daf0a6afe049162b744bc08f4 Mon Sep 17 00:00:00 2001 From: RuKira Date: Sat, 21 Mar 2026 04:45:49 -0400 Subject: [PATCH] Update: Full E2EE + Scripts --- RelayClient/App.xaml.cs | 39 ++- RelayClient/ChatSimulator.cs | 17 -- RelayClient/ClientSession.cs | 6 + RelayClient/Crypto/E2EeHelper.cs | 50 ++-- RelayClient/Crypto/KeyStorage.cs | 27 +- RelayClient/MainPage.xaml.cs | 202 ++++++++++---- RelayClient/MauiProgram.cs | 34 ++- RelayClient/Models/ServerPublicKeyMessage.cs | 7 + RelayClient/Models/SocketEncryptedMessage.cs | 12 + RelayCore/Program.cs | 4 +- RelayServer/Models/ClientPublicKeys.cs | 11 + RelayServer/Models/ServerEncryptionKeys.cs | 2 + RelayServer/Models/ServerPublicKeyMessage.cs | 7 + RelayServer/Models/SocketEncryptedMessage.cs | 12 + RelayServer/Program.cs | 95 ++----- RelayServer/Services/ChatTest.cs | 274 +++++++++++++++++++ RelayServer/Services/ClientKeyService.cs | 61 +++++ RelayServer/Services/CoreClientService.cs | 1 + RelayServer/Services/E2EeHelper.cs | 78 ++++++ dev-run.ps1 | 123 +++++++++ start-all.ps1 | 85 ++++++ 21 files changed, 940 insertions(+), 207 deletions(-) create mode 100644 RelayClient/ClientSession.cs create mode 100644 RelayClient/Models/ServerPublicKeyMessage.cs create mode 100644 RelayClient/Models/SocketEncryptedMessage.cs create mode 100644 RelayServer/Models/ClientPublicKeys.cs create mode 100644 RelayServer/Models/ServerPublicKeyMessage.cs create mode 100644 RelayServer/Models/SocketEncryptedMessage.cs create mode 100644 RelayServer/Services/ChatTest.cs create mode 100644 RelayServer/Services/ClientKeyService.cs create mode 100644 RelayServer/Services/E2EeHelper.cs create mode 100644 dev-run.ps1 create mode 100644 start-all.ps1 diff --git a/RelayClient/App.xaml.cs b/RelayClient/App.xaml.cs index 0311048..fea163f 100644 --- a/RelayClient/App.xaml.cs +++ b/RelayClient/App.xaml.cs @@ -2,37 +2,30 @@ public partial class App : Application { - private bool _openedSecondWindow; - public App() { InitializeComponent(); + + var username = Environment.GetCommandLineArgs() + .Skip(1) + .Chunk(2) + .Where(x => x.Length == 2 && x[0] == "--user") + .Select(x => x[1]) + .FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(username)) + { + throw new Exception("Missing required --user argument. Example: --user Keeper317"); + } + + ClientSession.Username = username; } protected override Window CreateWindow(IActivationState? activationState) { - var keeperWindow = new Window(new MainPage("Keeper317")) + return new Window(new MainPage(ClientSession.Username)) { - Title = "Relay Client - Keeper317" + Title = $"Relay Client - {ClientSession.Username}" }; - - keeperWindow.Created += KeeperWindow_Created; - - return keeperWindow; - } - - private void KeeperWindow_Created(object? sender, EventArgs e) - { - if (_openedSecondWindow) - return; - - _openedSecondWindow = false; - - var kiraWindow = new Window(new MainPage("Ru_Kira")) - { - Title = "Relay Client - Ru_Kira" - }; - - Current?.OpenWindow(kiraWindow); } } \ No newline at end of file diff --git a/RelayClient/ChatSimulator.cs b/RelayClient/ChatSimulator.cs index 21771b5..15d9ec1 100644 --- a/RelayClient/ChatSimulator.cs +++ b/RelayClient/ChatSimulator.cs @@ -1,22 +1,5 @@ namespace RelayClient; -public static class ChatSimulator -{ - public static event Action? MessageSent; - - public static void Send(string senderUsername, string text) - { - var message = new ChatMessage - { - SenderUsername = senderUsername, - Text = text, - Timestamp = DateTime.Now - }; - - MessageSent?.Invoke(message); - } -} - public sealed class ChatMessage { public required string SenderUsername { get; set; } diff --git a/RelayClient/ClientSession.cs b/RelayClient/ClientSession.cs new file mode 100644 index 0000000..f290b52 --- /dev/null +++ b/RelayClient/ClientSession.cs @@ -0,0 +1,6 @@ +namespace RelayClient; + +public static class ClientSession +{ + public static string Username { get; set; } = "Unknown"; +} \ No newline at end of file diff --git a/RelayClient/Crypto/E2EeHelper.cs b/RelayClient/Crypto/E2EeHelper.cs index 94de826..917e7c9 100644 --- a/RelayClient/Crypto/E2EeHelper.cs +++ b/RelayClient/Crypto/E2EeHelper.cs @@ -9,35 +9,33 @@ public static class E2EeHelper { using var rsa = RSA.Create(2048); - var publicKey = Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo()); - var privateKey = Convert.ToBase64String(rsa.ExportPkcs8PrivateKey()); - - return (publicKey, privateKey); + return ( + Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo()), + Convert.ToBase64String(rsa.ExportPkcs8PrivateKey()) + ); } - public static EncryptedMessagePayload EncryptForRecipient(string plainText, string recipientPublicKeyBase64) + public static EncryptedPayload EncryptForRecipient(string plainText, string recipientPublicKeyBase64) { - var aesKey = RandomNumberGenerator.GetBytes(32); - var nonce = RandomNumberGenerator.GetBytes(12); - var plainBytes = Encoding.UTF8.GetBytes(plainText); - var cipherBytes = new byte[plainBytes.Length]; - var tag = new byte[16]; + byte[] aesKey = RandomNumberGenerator.GetBytes(32); + byte[] nonce = RandomNumberGenerator.GetBytes(12); + byte[] plainBytes = Encoding.UTF8.GetBytes(plainText); + byte[] cipherBytes = new byte[plainBytes.Length]; + byte[] tag = new byte[16]; using (var aes = new AesGcm(aesKey, 16)) { aes.Encrypt(nonce, plainBytes, cipherBytes, tag); } - var recipientPublicKey = Convert.FromBase64String(recipientPublicKeyBase64); byte[] encryptedKey; - using (var rsa = RSA.Create()) { - rsa.ImportSubjectPublicKeyInfo(recipientPublicKey, out _); + rsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(recipientPublicKeyBase64), out _); encryptedKey = rsa.Encrypt(aesKey, RSAEncryptionPadding.OaepSHA256); } - return new EncryptedMessagePayload + return new EncryptedPayload { CipherText = Convert.ToBase64String(cipherBytes), Nonce = Convert.ToBase64String(nonce), @@ -46,34 +44,32 @@ public static class E2EeHelper }; } - public static string DecryptForRecipient(EncryptedMessagePayload payload, string recipientPrivateKeyBase64) + public static string DecryptForRecipient(EncryptedPayload payload, string recipientPrivateKeyBase64) { - var encryptedKey = Convert.FromBase64String(payload.EncryptedKey); - var privateKey = Convert.FromBase64String(recipientPrivateKeyBase64); - byte[] aesKey; - using (var rsa = RSA.Create()) { - rsa.ImportPkcs8PrivateKey(privateKey, out _); - aesKey = rsa.Decrypt(encryptedKey, RSAEncryptionPadding.OaepSHA256); + rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(recipientPrivateKeyBase64), out _); + aesKey = rsa.Decrypt(Convert.FromBase64String(payload.EncryptedKey), RSAEncryptionPadding.OaepSHA256); } - var nonce = Convert.FromBase64String(payload.Nonce); - var tag = Convert.FromBase64String(payload.Tag); - var cipherBytes = Convert.FromBase64String(payload.CipherText); - var plainBytes = new byte[cipherBytes.Length]; + byte[] plainBytes = new byte[Convert.FromBase64String(payload.CipherText).Length]; using (var aes = new AesGcm(aesKey, 16)) { - aes.Decrypt(nonce, cipherBytes, tag, plainBytes); + aes.Decrypt( + Convert.FromBase64String(payload.Nonce), + Convert.FromBase64String(payload.CipherText), + Convert.FromBase64String(payload.Tag), + plainBytes + ); } return Encoding.UTF8.GetString(plainBytes); } } -public class EncryptedMessagePayload +public class EncryptedPayload { public required string CipherText { get; set; } public required string Nonce { get; set; } diff --git a/RelayClient/Crypto/KeyStorage.cs b/RelayClient/Crypto/KeyStorage.cs index 4a8c4a6..502a254 100644 --- a/RelayClient/Crypto/KeyStorage.cs +++ b/RelayClient/Crypto/KeyStorage.cs @@ -2,19 +2,36 @@ namespace RelayClient.Crypto; public static class KeyStorage { + private static string GetKeyFolder() + { + var folder = Path.Combine(FileSystem.AppDataDirectory, "keys"); + Directory.CreateDirectory(folder); + return folder; + } + public static void SavePrivateKey(string username, string privateKey) { - Directory.CreateDirectory("keys"); - File.WriteAllText(Path.Combine("keys", $"{username}.private.key"), privateKey); + File.WriteAllText(Path.Combine(GetKeyFolder(), $"{username}.private.key"), privateKey); + } + + public static void SavePublicKey(string username, string publicKey) + { + File.WriteAllText(Path.Combine(GetKeyFolder(), $"{username}.public.key"), publicKey); } public static string LoadPrivateKey(string username) { - return File.ReadAllText(Path.Combine("keys", $"{username}.private.key")); + return File.ReadAllText(Path.Combine(GetKeyFolder(), $"{username}.private.key")); } - public static bool PrivateKeyExists(string username) + public static string LoadPublicKey(string username) { - return File.Exists(Path.Combine("keys", $"{username}.private.key")); + return File.ReadAllText(Path.Combine(GetKeyFolder(), $"{username}.public.key")); + } + + public static bool HasKeys(string username) + { + return File.Exists(Path.Combine(GetKeyFolder(), $"{username}.private.key")) && + File.Exists(Path.Combine(GetKeyFolder(), $"{username}.public.key")); } } \ No newline at end of file diff --git a/RelayClient/MainPage.xaml.cs b/RelayClient/MainPage.xaml.cs index 88921cc..b90b6f3 100644 --- a/RelayClient/MainPage.xaml.cs +++ b/RelayClient/MainPage.xaml.cs @@ -1,8 +1,15 @@ -namespace RelayClient; +using RelayClient.Crypto; +using RelayClient.Models; +using WebSocketSharp; +using System.Text.Json; + +namespace RelayClient; public partial class MainPage : ContentPage { private readonly string _username; + private readonly WebSocket wsc; + private string? _serverPublicKey; public MainPage(string username) { @@ -10,87 +17,188 @@ public partial class MainPage : ContentPage _username = username; UserLabel.Text = $"Logged in as: {_username}"; + + if (!KeyStorage.HasKeys(_username)) + { + var keys = E2EeHelper.GenerateRsaKeyPair(); + KeyStorage.SavePrivateKey(_username, keys.privateKey); + KeyStorage.SavePublicKey(_username, keys.publicKey); + } - ChatSimulator.MessageSent += OnMessageSent; + wsc = new WebSocket("ws://localhost:1337/"); + + wsc.OnMessage += WscOnMessage; + wsc.Connect(); + + var publicKey = KeyStorage.LoadPublicKey(_username); + wsc.Send($"REGISTER_KEY|{_username}|{publicKey}"); + wsc.Send("GET_SERVER_KEY"); + wsc.Send($"GET_HISTORY|{_username}"); } private void SendButton_OnClicked(object? sender, EventArgs e) { - SendMessage(sender, e); + SendMessage(); } private void MessageEntry_OnCompleted(object? sender, EventArgs e) { - SendMessage(sender, e); + SendMessage(); } - private void SendMessage(object? sender, EventArgs e) + private void SendMessage() { var text = MessageEntry.Text?.Trim(); if (string.IsNullOrWhiteSpace(text)) return; - - MauiProgram.wsc.Send($"{_username}:{text}"); - // ChatSimulator.Send(_username, text); + if (string.IsNullOrWhiteSpace(_serverPublicKey)) + { + Console.WriteLine("Server public key not loaded yet."); + return; + } - Console.WriteLine($"[{_username}] sent message: {text}"); - + var encrypted = E2EeHelper.EncryptForRecipient(text, _serverPublicKey); + + var payload = new SocketEncryptedMessage + { + Type = "client_encrypted_chat", + SenderUsername = _username, + CipherText = encrypted.CipherText, + Nonce = encrypted.Nonce, + Tag = encrypted.Tag, + EncryptedKey = encrypted.EncryptedKey + }; + + var json = JsonSerializer.Serialize(payload); + wsc.Send(json); + + Console.WriteLine($"[{_username}] sent encrypted message."); MessageEntry.Text = string.Empty; MessageEntry.Focus(); } - private void OnMessageSent(ChatMessage message) + private void WscOnMessage(object? sender, MessageEventArgs e) { - MainThread.BeginInvokeOnMainThread(async () => + if (e.Data.StartsWith("SERVER:REGISTERED_KEY:")) { - bool isOwnMessage = message.SenderUsername == _username; + Console.WriteLine(e.Data); + return; + } - var bubble = new Border + Console.WriteLine($"[{_username}] RAW WS DATA: {e.Data}"); + + try + { + using var doc = JsonDocument.Parse(e.Data); + var root = doc.RootElement; + + if (!root.TryGetProperty("Type", out var typeElement)) + return; + + var type = typeElement.GetString(); + + if (type == "server_public_key") { - StrokeThickness = 1, - Padding = 10, - Margin = isOwnMessage - ? new Thickness(40, 0, 0, 0) - : new Thickness(0, 0, 40, 0), - HorizontalOptions = isOwnMessage - ? LayoutOptions.End - : LayoutOptions.Start, - Content = new VerticalStackLayout + var serverKeyMessage = JsonSerializer.Deserialize(e.Data); + if (serverKeyMessage is not null) { - Spacing = 2, - Children = - { - new Label - { - Text = message.SenderUsername, - FontAttributes = FontAttributes.Bold, - FontSize = 12 - }, - new Label - { - Text = message.Text, - FontSize = 14 - }, - new Label - { - Text = message.Timestamp.ToString("h:mm tt"), - FontSize = 10 - } - } + _serverPublicKey = serverKeyMessage.PublicKey; + Console.WriteLine($"[{_username}] loaded server public key."); } + + return; + } + + if (type != "encrypted_chat") + return; + + var payload = JsonSerializer.Deserialize(e.Data); + if (payload is null) + return; + + if (payload.RecipientUsername != _username) + return; + + Console.WriteLine($"[{_username}] received encrypted payload for {payload.RecipientUsername}"); + + var privateKey = KeyStorage.LoadPrivateKey(_username); + + var decryptedText = E2EeHelper.DecryptForRecipient( + new EncryptedPayload + { + CipherText = payload.CipherText, + Nonce = payload.Nonce, + Tag = payload.Tag, + EncryptedKey = payload.EncryptedKey + }, + privateKey + ); + + Console.WriteLine($"[{_username}] decrypted message from {payload.SenderUsername}: {decryptedText}"); + + var message = new ChatMessage + { + SenderUsername = payload.SenderUsername, + Text = decryptedText, + Timestamp = DateTime.Now }; - MessagesLayout.Children.Add(bubble); - await MessagesScrollView.ScrollToAsync(MessagesLayout, ScrollToPosition.End, true); - }); + MainThread.BeginInvokeOnMainThread(async () => + { + bool isOwnMessage = message.SenderUsername == _username; + + var bubble = new Border + { + StrokeThickness = 1, + Padding = 10, + Margin = isOwnMessage + ? new Thickness(40, 0, 0, 0) + : new Thickness(0, 0, 40, 0), + HorizontalOptions = isOwnMessage + ? LayoutOptions.End + : LayoutOptions.Start, + Content = new VerticalStackLayout + { + Spacing = 2, + Children = + { + new Label + { + Text = message.SenderUsername, + FontAttributes = FontAttributes.Bold, + FontSize = 12 + }, + new Label + { + Text = message.Text, + FontSize = 14 + }, + new Label + { + Text = message.Timestamp.ToString("h:mm tt"), + FontSize = 10 + } + } + } + }; + + MessagesLayout.Children.Add(bubble); + await MessagesScrollView.ScrollToAsync(MessagesLayout, ScrollToPosition.End, true); + }); + } + catch (Exception ex) + { + Console.WriteLine($"[{_username}] failed to process websocket message: {ex.Message}"); + } } protected override void OnDisappearing() { - ChatSimulator.MessageSent -= OnMessageSent; + wsc.OnMessage -= WscOnMessage; + wsc.Close(); base.OnDisappearing(); } } \ No newline at end of file diff --git a/RelayClient/MauiProgram.cs b/RelayClient/MauiProgram.cs index f78c2f6..a5951be 100644 --- a/RelayClient/MauiProgram.cs +++ b/RelayClient/MauiProgram.cs @@ -6,11 +6,10 @@ namespace RelayClient; public static class MauiProgram { // public static event Action? MessageSent; - public static WebSocket wsc = new WebSocket("ws://localhost:1337"); public static MauiApp CreateMauiApp() { - wsc.OnMessage += (sender, e) => OnWebSocketRecieved(sender, e); - wsc.Connect(); + //wsc.OnMessage += (sender, e) => OnWebSocketRecieved(sender, e); + //wsc.Connect(); var builder = MauiApp.CreateBuilder(); builder.UseMauiApp().ConfigureFonts(fonts => { @@ -29,19 +28,18 @@ public static class MauiProgram return builder.Build(); } - public static void OnWebSocketRecieved(object? sender, MessageEventArgs e) - { - Console.WriteLine(sender.ToString()); - - ChatSimulator.Send(e.Data.Split(":")[0], e.Data.Split(":")[1]); - - // var message = new ChatMessage - // { - // SenderUsername = e.Data.Split(":")[0], - // Text = e.Data.Split(":")[1], - // Timestamp = DateTime.Now - // }; - // - // MessageSent?.Invoke(message); - } + //public static void OnWebSocketRecieved(object? sender, MessageEventArgs e) + //{ + // Console.WriteLine(sender.ToString()); + // + // ChatSimulator.Send(e.Data.Split(":")[0], e.Data.Split(":")[1]); + // // var message = new ChatMessage + // // { + // // SenderUsername = e.Data.Split(":")[0], + // // Text = e.Data.Split(":")[1], + // // Timestamp = DateTime.Now + // // }; + // // + // // MessageSent?.Invoke(message); + //} } \ No newline at end of file diff --git a/RelayClient/Models/ServerPublicKeyMessage.cs b/RelayClient/Models/ServerPublicKeyMessage.cs new file mode 100644 index 0000000..d59b7c9 --- /dev/null +++ b/RelayClient/Models/ServerPublicKeyMessage.cs @@ -0,0 +1,7 @@ +namespace RelayClient.Models; + +public class ServerPublicKeyMessage +{ + public required string Type { get; set; } + public required string PublicKey { get; set; } +} \ No newline at end of file diff --git a/RelayClient/Models/SocketEncryptedMessage.cs b/RelayClient/Models/SocketEncryptedMessage.cs new file mode 100644 index 0000000..f36863f --- /dev/null +++ b/RelayClient/Models/SocketEncryptedMessage.cs @@ -0,0 +1,12 @@ +namespace RelayClient.Models; + +public class SocketEncryptedMessage +{ + public required string Type { get; set; } + public required string SenderUsername { get; set; } + public string? RecipientUsername { get; set; } + public required string CipherText { get; set; } + public required string Nonce { get; set; } + public required string Tag { get; set; } + public required string EncryptedKey { get; set; } +} \ No newline at end of file diff --git a/RelayCore/Program.cs b/RelayCore/Program.cs index c3fbd64..ff77fe2 100644 --- a/RelayCore/Program.cs +++ b/RelayCore/Program.cs @@ -1,8 +1,6 @@ using SurrealDb.Net; -using SurrealDb.Net.Models; using SurrealDb.Net.Models.Auth; using System.Text.Json; -using RelayCore; using RelayCore.Enums; using RelayCore.Models; @@ -13,9 +11,11 @@ await db.Use("test", "test"); var keeper = await CreateUserAsync(db, "Keeper317", "Keeper317@gmail.com", "password"); var kira = await CreateUserAsync(db, "Ru_Kira", "jduesling13@gmail.com", "password"); +var test = await CreateUserAsync(db, "Test", "test@gmail.com", "password"); Console.WriteLine($"Keeper created: {ToJsonString(keeper)}"); Console.WriteLine($"Kira created: {ToJsonString(kira)}"); +Console.WriteLine($"Test created: {ToJsonString(test)}"); Console.ReadKey(true); return; diff --git a/RelayServer/Models/ClientPublicKeys.cs b/RelayServer/Models/ClientPublicKeys.cs new file mode 100644 index 0000000..04c2c21 --- /dev/null +++ b/RelayServer/Models/ClientPublicKeys.cs @@ -0,0 +1,11 @@ +using SurrealDb.Net.Models; + +namespace RelayServer.Models; + +public class ClientPublicKeys : Record +{ + public required string Username { get; set; } + public required string PublicKey { get; set; } + public required DateTime CreatedAt { get; set; } + public required DateTime UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/RelayServer/Models/ServerEncryptionKeys.cs b/RelayServer/Models/ServerEncryptionKeys.cs index b94e139..1a3d2fa 100644 --- a/RelayServer/Models/ServerEncryptionKeys.cs +++ b/RelayServer/Models/ServerEncryptionKeys.cs @@ -5,6 +5,8 @@ namespace RelayServer.Models; public class ServerEncryptionKeys : Record { public required string KeyBase64 { get; set; } + public required string PublicKey { get; set; } + public required string PrivateKey { get; set; } public required DateTime CreatedAt { get; set; } public required DateTime UpdatedAt { get; set; } } \ No newline at end of file diff --git a/RelayServer/Models/ServerPublicKeyMessage.cs b/RelayServer/Models/ServerPublicKeyMessage.cs new file mode 100644 index 0000000..e5cc4f9 --- /dev/null +++ b/RelayServer/Models/ServerPublicKeyMessage.cs @@ -0,0 +1,7 @@ +namespace RelayServer.Models; + +public class ServerPublicKeyMessage +{ + public required string Type { get; set; } + public required string PublicKey { get; set; } +} \ No newline at end of file diff --git a/RelayServer/Models/SocketEncryptedMessage.cs b/RelayServer/Models/SocketEncryptedMessage.cs new file mode 100644 index 0000000..472e82c --- /dev/null +++ b/RelayServer/Models/SocketEncryptedMessage.cs @@ -0,0 +1,12 @@ +namespace RelayServer.Models; + +public class SocketEncryptedMessage +{ + public required string Type { get; set; } + public required string SenderUsername { get; set; } + public required string RecipientUsername { get; set; } + public required string CipherText { get; set; } + public required string Nonce { get; set; } + public required string Tag { get; set; } + public required string EncryptedKey { get; set; } +} \ No newline at end of file diff --git a/RelayServer/Program.cs b/RelayServer/Program.cs index ad36140..2291580 100644 --- a/RelayServer/Program.cs +++ b/RelayServer/Program.cs @@ -1,10 +1,7 @@ using System.Text.Json; -using System; -using WebSocketSharp.Server; -using WebSocketSharp; - -using RelayServer.Models; using RelayServer.Services; +using WebSocketSharp.Server; +using RelayServer.Models; var surrealService = new SurrealService(); var coreClient = new CoreClientService(); @@ -12,23 +9,25 @@ var cryptoService = new ChannelCryptoService(); await using var db = await surrealService.ConnectAsync(); +ChatTest.ClientKeyService = new ClientKeyService(db); +ChatTest.Db = db; + var wssv = new WebSocketServer("ws://localhost:1337"); wssv.AddWebSocketService("/"); wssv.Start(); Console.WriteLine("WebSocket server started"); -Console.ReadKey(true); -wssv.Stop(); var keeper = await coreClient.GetUserByUsernameAsync("Keeper317"); var kira = await coreClient.GetUserByUsernameAsync("Ru_Kira"); +var test = await coreClient.GetUserByUsernameAsync("Test"); -if (keeper is null || kira is null) +if (keeper is null || kira is null || test is null) { Console.WriteLine("One or more required users do not exist in RelayCore."); return; } -if (!keeper.Licensed || !kira.Licensed) +if (!keeper.Licensed || !kira.Licensed || !test.Licensed) { Console.WriteLine("One or more required users are not licensed."); return; @@ -36,11 +35,12 @@ if (!keeper.Licensed || !kira.Licensed) Console.WriteLine($"Core verified user: {keeper.Username}"); Console.WriteLine($"Core verified user: {kira.Username}"); +Console.WriteLine($"Core verified user: {test.Username}"); var server = await db.Create("servers", new Servers { Name = "Test Server", - OwnerUserId = kira.Id, + OwnerUserId = keeper.Id, CreatedAt = DateTime.UtcNow }); @@ -60,6 +60,13 @@ var kiraMember = await db.Create("server_members", new ServerMembers IsOwner = false }); +var testMember = await db.Create("server_members", new ServerMembers +{ + UserId = test.Id, + JoinedAt = DateTime.UtcNow, + IsOwner = false +}); + Console.WriteLine("Server members created."); var channel = await db.Create("channels", new Channels @@ -72,66 +79,29 @@ Console.WriteLine($"Channel created: {ToJsonString(channel)}"); var channelId = GetRecordId(channel.Id); Console.WriteLine($"Resolved channelId: {channelId}"); +ChatTest.DefaultChannelId = channelId; var keyBase64 = cryptoService.GenerateKey(); +var serverKeys = E2EeHelper.GenerateRsaKeyPair(); var serverKey = await db.Create("server_encryption_keys", new ServerEncryptionKeys { KeyBase64 = keyBase64, + PublicKey = serverKeys.publicKey, + PrivateKey = serverKeys.privateKey, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }); +ChatTest.ServerPublicKey = serverKeys.publicKey; +ChatTest.ServerPrivateKey = serverKeys.privateKey; +ChatTest.ChannelDbKey = keyBase64; + Console.WriteLine("Server encryption key created."); -var encrypted = cryptoService.Encrypt("hello from Keeper317 in #general", keyBase64); +Console.ReadKey(true); +wssv.Stop(); -var savedMessage = await db.Create("channel_messages", new ChannelMessages -{ - ChannelId = channelId, - SenderUserId = keeper.Id, - CipherText = encrypted.cipherText, - Nonce = encrypted.nonce, - Tag = encrypted.tag, - CreatedAt = DateTime.UtcNow -}); - -Console.WriteLine($"Encrypted message saved: {ToJsonString(savedMessage)}"); - -var decrypted = cryptoService.Decrypt( - savedMessage.CipherText, - savedMessage.Nonce, - savedMessage.Tag, - keyBase64 -); - -var storedMessages = await db.Select("channel_messages"); - -Console.WriteLine("Stored DB messages:"); -Console.WriteLine(ToJsonString(storedMessages)); - -Console.WriteLine(); -Console.WriteLine($"Decrypted message: {decrypted}"); - -Console.WriteLine(); -Console.WriteLine("Simulating Kira reading #general..."); - -var kiraVisibleMessages = storedMessages - .Where(m => m.ChannelId == channelId) - .OrderBy(m => m.CreatedAt) - .ToList(); - -foreach (var msg in kiraVisibleMessages) -{ - var plainText = cryptoService.Decrypt( - msg.CipherText, - msg.Nonce, - msg.Tag, - keyBase64 - ); - - Console.WriteLine($"Kira reads message from {msg.SenderUserId}: {plainText}"); -} return; static string ToJsonString(object? obj) @@ -158,15 +128,4 @@ static string GetRecordId(object? id) var table = root.GetProperty("Table").GetString() ?? string.Empty; return $"{table}:{recordId}"; -} - -public class ChatTest : WebSocketBehavior -{ - protected override void OnMessage(MessageEventArgs e) - { - // var msg = e.Data.Split(":")[1] == "PING" ? "SERVER:PONG" : "SERVER:RESPONSE"; - var msg = e.Data; - Console.WriteLine(msg); - Send(msg); - } } \ No newline at end of file diff --git a/RelayServer/Services/ChatTest.cs b/RelayServer/Services/ChatTest.cs new file mode 100644 index 0000000..39e5bf2 --- /dev/null +++ b/RelayServer/Services/ChatTest.cs @@ -0,0 +1,274 @@ +using System.Text.Json; +using RelayServer.Models; +using WebSocketSharp; +using WebSocketSharp.Server; + +namespace RelayServer.Services; + +public class ChatTest : WebSocketBehavior +{ + public static ClientKeyService? ClientKeyService { get; set; } + public static string? ServerPublicKey { get; set; } + public static string? ServerPrivateKey { get; set; } + public static string? ChannelDbKey { get; set; } + public static SurrealDb.Net.SurrealDbClient? Db { get; set; } + public static string? DefaultChannelId { get; set; } + + protected override void OnMessage(MessageEventArgs e) + { + var msg = e.Data; + Console.WriteLine(msg); + + if (msg.StartsWith("REGISTER_KEY|")) + { + HandleRegisterKey(msg); + return; + } + + if (msg == "GET_SERVER_KEY") + { + HandleGetServerKey(); + return; + } + + if (msg.StartsWith("GET_HISTORY|")) + { + HandleGetHistory(msg); + return; + } + + HandleEncryptedClientMessage(msg); + } + + private static string ExtractUsernameFromUserId(string senderUserId) + { + if (string.IsNullOrWhiteSpace(senderUserId)) + return "Unknown"; + + var parts = senderUserId.Split(':', 2); + return parts.Length == 2 ? parts[1] : senderUserId; + } + + private void HandleRegisterKey(string msg) + { + var parts = msg.Split('|', 3); + + if (parts.Length < 3) + { + Console.WriteLine("Invalid REGISTER_KEY payload."); + return; + } + + var username = parts[1]; + var publicKey = parts[2]; + + if (ClientKeyService is null) + { + Console.WriteLine("ClientKeyService is not initialized."); + return; + } + + Task.Run(async () => + { + await ClientKeyService.RegisterOrUpdateKeyAsync(username, publicKey); + }).GetAwaiter().GetResult(); + + Send($"SERVER:REGISTERED_KEY:{username}"); + } + + private void HandleGetServerKey() + { + if (string.IsNullOrWhiteSpace(ServerPublicKey)) + { + Console.WriteLine("Server public key is not initialized."); + return; + } + + var payload = new ServerPublicKeyMessage + { + Type = "server_public_key", + PublicKey = ServerPublicKey + }; + + Send(JsonSerializer.Serialize(payload)); + } + + private void HandleEncryptedClientMessage(string msg) + { + SocketEncryptedMessage? clientPayload; + + try + { + clientPayload = JsonSerializer.Deserialize(msg); + } + catch + { + Console.WriteLine("Failed to parse encrypted client payload."); + return; + } + + if (clientPayload is null || clientPayload.Type != "client_encrypted_chat") + return; + + if (ClientKeyService is null || + Db is null || + string.IsNullOrWhiteSpace(ServerPrivateKey) || + string.IsNullOrWhiteSpace(ChannelDbKey) || + string.IsNullOrWhiteSpace(DefaultChannelId)) + { + Console.WriteLine("Server crypto/database dependencies are not initialized."); + return; + } + + string plainText; + + try + { + plainText = E2EeHelper.DecryptForRecipient( + new EncryptedPayload + { + CipherText = clientPayload.CipherText, + Nonce = clientPayload.Nonce, + Tag = clientPayload.Tag, + EncryptedKey = clientPayload.EncryptedKey + }, + ServerPrivateKey + ); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to decrypt client payload: {ex.Message}"); + return; + } + + Console.WriteLine($"Server decrypted message from {clientPayload.SenderUsername}: {plainText}"); + try + { + var channelCrypto = new ChannelCryptoService(); + var dbEncrypted = channelCrypto.Encrypt(plainText, ChannelDbKey); + + var savedMessage = Task.Run(async () => + await Db.Create("channel_messages", new ChannelMessages + { + ChannelId = DefaultChannelId, + SenderUserId = $"users:{clientPayload.SenderUsername.ToLower()}", + CipherText = dbEncrypted.cipherText, + Nonce = dbEncrypted.nonce, + Tag = dbEncrypted.tag, + CreatedAt = DateTime.UtcNow + }) + ).GetAwaiter().GetResult(); + + Console.WriteLine($"Live message saved to DB: {JsonSerializer.Serialize(savedMessage)}"); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to save live message to DB: {ex.Message}"); + return; + } + + var allKeys = Task.Run(async () => await ClientKeyService.GetAllAsync()) + .GetAwaiter() + .GetResult(); + + foreach (var client in allKeys) + { + var encrypted = E2EeHelper.EncryptForRecipient(plainText, client.PublicKey); + + Console.WriteLine($"Encrypting outbound message from {clientPayload.SenderUsername} for {client.Username}"); + + var outbound = new SocketEncryptedMessage + { + Type = "encrypted_chat", + SenderUsername = clientPayload.SenderUsername, + RecipientUsername = client.Username, + CipherText = encrypted.CipherText, + Nonce = encrypted.Nonce, + Tag = encrypted.Tag, + EncryptedKey = encrypted.EncryptedKey + }; + + Sessions.Broadcast(JsonSerializer.Serialize(outbound)); + } + } + + private void HandleGetHistory(string msg) + { + var parts = msg.Split('|', 2); + + if (parts.Length < 2) + { + Console.WriteLine("Invalid GET_HISTORY payload."); + return; + } + + var username = parts[1]; + + if (ClientKeyService is null || + Db is null || + string.IsNullOrWhiteSpace(ChannelDbKey) || + string.IsNullOrWhiteSpace(DefaultChannelId)) + { + Console.WriteLine("History dependencies are not initialized."); + return; + } + + var targetClient = Task.Run(async () => await ClientKeyService.GetByUsernameAsync(username)) + .GetAwaiter() + .GetResult(); + + if (targetClient is null) + { + Console.WriteLine($"No public key found for history request user {username}"); + return; + } + + var allMessages = Task.Run(async () => await Db.Select("channel_messages")) + .GetAwaiter() + .GetResult(); + + var channelMessages = allMessages + .Where(m => m.ChannelId == DefaultChannelId) + .OrderBy(m => m.CreatedAt) + .ToList(); + + Console.WriteLine($"Sending {channelMessages.Count} history messages to {username}"); + + var channelCrypto = new ChannelCryptoService(); + + foreach (var dbMessage in channelMessages) + { + string plainText; + + try + { + plainText = channelCrypto.Decrypt( + dbMessage.CipherText, + dbMessage.Nonce, + dbMessage.Tag, + ChannelDbKey + ); + } + catch (Exception ex) + { + Console.WriteLine($"Failed to decrypt DB history row {dbMessage.Id}: {ex.Message}"); + continue; + } + + var encrypted = E2EeHelper.EncryptForRecipient(plainText, targetClient.PublicKey); + + var outbound = new SocketEncryptedMessage + { + Type = "encrypted_chat", + SenderUsername = ExtractUsernameFromUserId(dbMessage.SenderUserId), + RecipientUsername = username, + CipherText = encrypted.CipherText, + Nonce = encrypted.Nonce, + Tag = encrypted.Tag, + EncryptedKey = encrypted.EncryptedKey + }; + + Send(JsonSerializer.Serialize(outbound)); + } + } +} \ No newline at end of file diff --git a/RelayServer/Services/ClientKeyService.cs b/RelayServer/Services/ClientKeyService.cs new file mode 100644 index 0000000..8f2ab2f --- /dev/null +++ b/RelayServer/Services/ClientKeyService.cs @@ -0,0 +1,61 @@ +using RelayServer.Models; +using SurrealDb.Net; + +namespace RelayServer.Services; + +public sealed class ClientKeyService +{ + private readonly SurrealDbClient _db; + + public ClientKeyService(SurrealDbClient db) + { + _db = db; + } + + public async Task RegisterOrUpdateKeyAsync(string username, string publicKey) + { + var allKeys = await _db.Select("client_public_keys"); + + var existing = allKeys.FirstOrDefault(x => x.Username == username); + + if (existing is null) + { + await _db.Create("client_public_keys", new ClientPublicKeys + { + Username = username, + PublicKey = publicKey, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }); + + Console.WriteLine($"Stored public key for {username}"); + return; + } + + existing.PublicKey = publicKey; + existing.UpdatedAt = DateTime.UtcNow; + + await _db.Merge(new ClientPublicKeys + { + Id = existing.Id, + Username = existing.Username, + PublicKey = existing.PublicKey, + CreatedAt = existing.CreatedAt, + UpdatedAt = existing.UpdatedAt + }); + + Console.WriteLine($"Updated public key for {username}"); + } + + public async Task GetByUsernameAsync(string username) + { + var allKeys = await _db.Select("client_public_keys"); + return allKeys.FirstOrDefault(x => x.Username == username); + } + + public async Task> GetAllAsync() + { + var allKeys = await _db.Select("client_public_keys"); + return allKeys.ToList(); + } +} \ No newline at end of file diff --git a/RelayServer/Services/CoreClientService.cs b/RelayServer/Services/CoreClientService.cs index d592f74..b452dbb 100644 --- a/RelayServer/Services/CoreClientService.cs +++ b/RelayServer/Services/CoreClientService.cs @@ -8,6 +8,7 @@ public sealed class CoreClientService { "Keeper317" => new CoreUser("users:keeper317", "Keeper317", true), "Ru_Kira" => new CoreUser("users:ru_kira", "Ru_Kira", true), + "Test" => new CoreUser("users:test", "Test", true), _ => null }); } diff --git a/RelayServer/Services/E2EeHelper.cs b/RelayServer/Services/E2EeHelper.cs new file mode 100644 index 0000000..35a0547 --- /dev/null +++ b/RelayServer/Services/E2EeHelper.cs @@ -0,0 +1,78 @@ +using System.Security.Cryptography; +using System.Text; + +namespace RelayServer.Services; + +public static class E2EeHelper +{ + public static (string publicKey, string privateKey) GenerateRsaKeyPair() + { + using var rsa = RSA.Create(2048); + + return ( + Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo()), + Convert.ToBase64String(rsa.ExportPkcs8PrivateKey()) + ); + } + + public static EncryptedPayload EncryptForRecipient(string plainText, string recipientPublicKeyBase64) + { + byte[] aesKey = RandomNumberGenerator.GetBytes(32); + byte[] nonce = RandomNumberGenerator.GetBytes(12); + byte[] plainBytes = Encoding.UTF8.GetBytes(plainText); + byte[] cipherBytes = new byte[plainBytes.Length]; + byte[] tag = new byte[16]; + + using (var aes = new AesGcm(aesKey, 16)) + { + aes.Encrypt(nonce, plainBytes, cipherBytes, tag); + } + + byte[] encryptedKey; + using (var rsa = RSA.Create()) + { + rsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(recipientPublicKeyBase64), out _); + encryptedKey = rsa.Encrypt(aesKey, RSAEncryptionPadding.OaepSHA256); + } + + return new EncryptedPayload + { + CipherText = Convert.ToBase64String(cipherBytes), + Nonce = Convert.ToBase64String(nonce), + Tag = Convert.ToBase64String(tag), + EncryptedKey = Convert.ToBase64String(encryptedKey) + }; + } + + public static string DecryptForRecipient(EncryptedPayload payload, string recipientPrivateKeyBase64) + { + byte[] aesKey; + using (var rsa = RSA.Create()) + { + rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(recipientPrivateKeyBase64), out _); + aesKey = rsa.Decrypt(Convert.FromBase64String(payload.EncryptedKey), RSAEncryptionPadding.OaepSHA256); + } + + byte[] plainBytes = new byte[Convert.FromBase64String(payload.CipherText).Length]; + + using (var aes = new AesGcm(aesKey, 16)) + { + aes.Decrypt( + Convert.FromBase64String(payload.Nonce), + Convert.FromBase64String(payload.CipherText), + Convert.FromBase64String(payload.Tag), + plainBytes + ); + } + + return Encoding.UTF8.GetString(plainBytes); + } +} + +public class EncryptedPayload +{ + public required string CipherText { get; set; } + public required string Nonce { get; set; } + public required string Tag { get; set; } + public required string EncryptedKey { get; set; } +} \ No newline at end of file diff --git a/dev-run.ps1 b/dev-run.ps1 new file mode 100644 index 0000000..aea8f61 --- /dev/null +++ b/dev-run.ps1 @@ -0,0 +1,123 @@ +$root = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $root + +$startedProcesses = @() + +function Start-TrackedProcess { + param( + [string]$FilePath, + [string[]]$ArgumentList = @(), + [string]$WorkingDirectory = $root + ) + + $proc = Start-Process -FilePath $FilePath ` + -ArgumentList $ArgumentList ` + -WorkingDirectory $WorkingDirectory ` + -PassThru + + $script:startedProcesses += $proc + return $proc +} + +function Stop-AllTrackedProcesses { + Write-Host "" + Write-Host "Stopping launched processes..." + + foreach ($proc in $script:startedProcesses) { + try { + if ($proc -and -not $proc.HasExited) { + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + Write-Host "Stopped PID $($proc.Id)" + } + } + catch { + } + } + + try { + $containerIds = docker ps --filter "ancestor=surrealdb/surrealdb:v2.2.1" --format "{{.ID}}" + foreach ($id in $containerIds) { + docker stop $id | Out-Null + Write-Host "Stopped Docker container $id" + } + } + catch { + } +} + +Register-EngineEvent PowerShell.Exiting -Action { + Stop-AllTrackedProcesses +} | Out-Null + +Write-Host "Building RelayCore..." +dotnet build .\RelayCore\RelayCore.csproj +if ($LASTEXITCODE -ne 0) { throw "RelayCore build failed." } + +Write-Host "Building RelayServer..." +dotnet build .\RelayServer\RelayServer.csproj +if ($LASTEXITCODE -ne 0) { throw "RelayServer build failed." } + +Write-Host "Building RelayClient (Windows only)..." +dotnet build .\RelayClient\RelayClient.csproj -f net10.0-windows10.0.19041.0 +if ($LASTEXITCODE -ne 0) { throw "RelayClient build failed." } + +$coreDll = Join-Path $root "RelayCore\bin\Debug\net9.0\RelayCore.dll" +$serverDll = Join-Path $root "RelayServer\bin\Debug\net10.0\RelayServer.dll" +$clientExe = Join-Path $root "RelayClient\bin\Debug\net10.0-windows10.0.19041.0\win-x64\RelayClient.exe" + +Write-Host "Starting SurrealDB..." +Start-TrackedProcess ` + -FilePath "docker" ` + -ArgumentList @( + "run", + "--rm", + "-p", "8000:8000", + "-v", "/mydata:/mydata", + "surrealdb/surrealdb:v2.2.1", + "start", + "--user", "root", + "--pass", "secret" +) + +Start-Sleep -Seconds 5 + +Write-Host "Starting RelayCore..." +Start-TrackedProcess ` + -FilePath "dotnet" ` + -ArgumentList @($coreDll) + +Start-Sleep -Seconds 3 + +Write-Host "Starting RelayServer..." +Start-TrackedProcess ` + -FilePath "dotnet" ` + -ArgumentList @($serverDll) + +Start-Sleep -Seconds 3 + +Write-Host "Starting RelayClient (Keeper317)..." +Start-TrackedProcess ` + -FilePath $clientExe ` + -ArgumentList @("--user", "Keeper317") + +Start-Sleep -Seconds 1 + +Write-Host "Starting RelayClient (Ru_Kira)..." +Start-TrackedProcess ` + -FilePath $clientExe ` + -ArgumentList @("--user", "Ru_Kira") + +Start-Sleep -Seconds 20 + +Write-Host "Starting RelayClient (Test)..." +Start-TrackedProcess ` + -FilePath $clientExe ` + -ArgumentList @("--user", "Test") + +Write-Host "" +Write-Host "Everything started." +Write-Host "Press Ctrl+C in this PowerShell to stop everything." + +while ($true) { + Start-Sleep -Seconds 1 +} \ No newline at end of file diff --git a/start-all.ps1 b/start-all.ps1 new file mode 100644 index 0000000..ae62351 --- /dev/null +++ b/start-all.ps1 @@ -0,0 +1,85 @@ +$root = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $root + +$dockerExe = (Get-Command docker.exe).Source +$dotnetExe = (Get-Command dotnet.exe).Source +$ps = (Get-Command powershell.exe).Source + +Write-Host "Building RelayCore..." +& $dotnetExe build .\RelayCore\RelayCore.csproj +if ($LASTEXITCODE -ne 0) { throw "RelayCore build failed." } + +Write-Host "Building RelayServer..." +& $dotnetExe build .\RelayServer\RelayServer.csproj +if ($LASTEXITCODE -ne 0) { throw "RelayServer build failed." } + +Write-Host "Building RelayClient (Windows only)..." +& $dotnetExe build .\RelayClient\RelayClient.csproj -f net10.0-windows10.0.19041.0 +if ($LASTEXITCODE -ne 0) { throw "RelayClient build failed." } + +$coreDll = Join-Path $root "RelayCore\bin\Debug\net9.0\RelayCore.dll" +$serverDll = Join-Path $root "RelayServer\bin\Debug\net10.0\RelayServer.dll" +$clientExe = Join-Path $root "RelayClient\bin\Debug\net10.0-windows10.0.19041.0\win-x64\RelayClient.exe" + +$tempDir = Join-Path $env:TEMP "RelayTabs" +New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + +function New-TabScript { + param( + [string]$Name, + [string]$Content + ) + + $path = Join-Path $tempDir "$Name.ps1" + Set-Content -Path $path -Value $Content -Encoding UTF8 + return $path +} + +$dockerScript = New-TabScript -Name "SurrealDB" -Content @" +Set-Location '$root' +& '$dockerExe' run --rm -p 8000:8000 -v /mydata:/mydata surrealdb/surrealdb:v2.2.1 start --user root --pass secret +"@ + +$coreScript = New-TabScript -Name "RelayCore" -Content @" +Set-Location '$root' +Start-Sleep -Seconds 1 +& '$dotnetExe' '$coreDll' +"@ + +$serverScript = New-TabScript -Name "RelayServer" -Content @" +Set-Location '$root' +Start-Sleep -Seconds 1 +& '$dotnetExe' '$serverDll' +"@ + +$keeperScript = New-TabScript -Name "Keeper317" -Content @" +Set-Location '$root' +Start-Sleep -Seconds 5 +& '$clientExe' --user Keeper317 +"@ + +$kiraScript = New-TabScript -Name "Ru_Kira" -Content @" +Set-Location '$root' +Start-Sleep -Seconds 5 +& '$clientExe' --user Ru_Kira +"@ + +$testScript = New-TabScript -Name "Test" -Content @" +Set-Location '$root' +Start-Sleep -Seconds 25 +& '$clientExe' --user Test +"@ + +$wtArgs = @( + "new-tab --title `"SurrealDB`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$dockerScript`"", + "new-tab --title `"RelayCore`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$coreScript`"", + "new-tab --title `"RelayServer`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$serverScript`"", + "new-tab --title `"Keeper317`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$keeperScript`"", + "new-tab --title `"Ru_Kira`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$kiraScript`"", + "new-tab --title `"Test`" `"$ps`" -NoExit -ExecutionPolicy Bypass -File `"$testScript`"" +) -join " ; " + +Write-Host "" +Write-Host "Everything started." +Write-Host "Close out terminal to end all applications." +Start-Process wt.exe -ArgumentList $wtArgs \ No newline at end of file