Update: Full E2EE + Scripts

This commit is contained in:
2026-03-21 04:45:49 -04:00
parent cc31c4024a
commit 8a771220e4
21 changed files with 940 additions and 207 deletions

View File

@@ -2,37 +2,30 @@
public partial class App : Application public partial class App : Application
{ {
private bool _openedSecondWindow;
public App() public App()
{ {
InitializeComponent(); 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) 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);
} }
} }

View File

@@ -1,22 +1,5 @@
namespace RelayClient; namespace RelayClient;
public static class ChatSimulator
{
public static event Action<ChatMessage>? 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 sealed class ChatMessage
{ {
public required string SenderUsername { get; set; } public required string SenderUsername { get; set; }

View File

@@ -0,0 +1,6 @@
namespace RelayClient;
public static class ClientSession
{
public static string Username { get; set; } = "Unknown";
}

View File

@@ -9,35 +9,33 @@ public static class E2EeHelper
{ {
using var rsa = RSA.Create(2048); using var rsa = RSA.Create(2048);
var publicKey = Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo()); return (
var privateKey = Convert.ToBase64String(rsa.ExportPkcs8PrivateKey()); Convert.ToBase64String(rsa.ExportSubjectPublicKeyInfo()),
Convert.ToBase64String(rsa.ExportPkcs8PrivateKey())
return (publicKey, privateKey); );
} }
public static EncryptedMessagePayload EncryptForRecipient(string plainText, string recipientPublicKeyBase64) public static EncryptedPayload EncryptForRecipient(string plainText, string recipientPublicKeyBase64)
{ {
var aesKey = RandomNumberGenerator.GetBytes(32); byte[] aesKey = RandomNumberGenerator.GetBytes(32);
var nonce = RandomNumberGenerator.GetBytes(12); byte[] nonce = RandomNumberGenerator.GetBytes(12);
var plainBytes = Encoding.UTF8.GetBytes(plainText); byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
var cipherBytes = new byte[plainBytes.Length]; byte[] cipherBytes = new byte[plainBytes.Length];
var tag = new byte[16]; byte[] tag = new byte[16];
using (var aes = new AesGcm(aesKey, 16)) using (var aes = new AesGcm(aesKey, 16))
{ {
aes.Encrypt(nonce, plainBytes, cipherBytes, tag); aes.Encrypt(nonce, plainBytes, cipherBytes, tag);
} }
var recipientPublicKey = Convert.FromBase64String(recipientPublicKeyBase64);
byte[] encryptedKey; byte[] encryptedKey;
using (var rsa = RSA.Create()) using (var rsa = RSA.Create())
{ {
rsa.ImportSubjectPublicKeyInfo(recipientPublicKey, out _); rsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(recipientPublicKeyBase64), out _);
encryptedKey = rsa.Encrypt(aesKey, RSAEncryptionPadding.OaepSHA256); encryptedKey = rsa.Encrypt(aesKey, RSAEncryptionPadding.OaepSHA256);
} }
return new EncryptedMessagePayload return new EncryptedPayload
{ {
CipherText = Convert.ToBase64String(cipherBytes), CipherText = Convert.ToBase64String(cipherBytes),
Nonce = Convert.ToBase64String(nonce), 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; byte[] aesKey;
using (var rsa = RSA.Create()) using (var rsa = RSA.Create())
{ {
rsa.ImportPkcs8PrivateKey(privateKey, out _); rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(recipientPrivateKeyBase64), out _);
aesKey = rsa.Decrypt(encryptedKey, RSAEncryptionPadding.OaepSHA256); aesKey = rsa.Decrypt(Convert.FromBase64String(payload.EncryptedKey), RSAEncryptionPadding.OaepSHA256);
} }
var nonce = Convert.FromBase64String(payload.Nonce); byte[] plainBytes = new byte[Convert.FromBase64String(payload.CipherText).Length];
var tag = Convert.FromBase64String(payload.Tag);
var cipherBytes = Convert.FromBase64String(payload.CipherText);
var plainBytes = new byte[cipherBytes.Length];
using (var aes = new AesGcm(aesKey, 16)) 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); return Encoding.UTF8.GetString(plainBytes);
} }
} }
public class EncryptedMessagePayload public class EncryptedPayload
{ {
public required string CipherText { get; set; } public required string CipherText { get; set; }
public required string Nonce { get; set; } public required string Nonce { get; set; }

View File

@@ -2,19 +2,36 @@ namespace RelayClient.Crypto;
public static class KeyStorage 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) public static void SavePrivateKey(string username, string privateKey)
{ {
Directory.CreateDirectory("keys"); File.WriteAllText(Path.Combine(GetKeyFolder(), $"{username}.private.key"), privateKey);
File.WriteAllText(Path.Combine("keys", $"{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) 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"));
} }
} }

View File

@@ -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 public partial class MainPage : ContentPage
{ {
private readonly string _username; private readonly string _username;
private readonly WebSocket wsc;
private string? _serverPublicKey;
public MainPage(string username) public MainPage(string username)
{ {
@@ -10,87 +17,188 @@ public partial class MainPage : ContentPage
_username = username; _username = username;
UserLabel.Text = $"Logged in as: {_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) private void SendButton_OnClicked(object? sender, EventArgs e)
{ {
SendMessage(sender, e); SendMessage();
} }
private void MessageEntry_OnCompleted(object? sender, EventArgs e) 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(); var text = MessageEntry.Text?.Trim();
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
return; 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.Text = string.Empty;
MessageEntry.Focus(); 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, var serverKeyMessage = JsonSerializer.Deserialize<ServerPublicKeyMessage>(e.Data);
Padding = 10, if (serverKeyMessage is not null)
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, _serverPublicKey = serverKeyMessage.PublicKey;
Children = Console.WriteLine($"[{_username}] loaded server public key.");
{
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
}
}
} }
return;
}
if (type != "encrypted_chat")
return;
var payload = JsonSerializer.Deserialize<SocketEncryptedMessage>(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); MainThread.BeginInvokeOnMainThread(async () =>
await MessagesScrollView.ScrollToAsync(MessagesLayout, ScrollToPosition.End, true); {
}); 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() protected override void OnDisappearing()
{ {
ChatSimulator.MessageSent -= OnMessageSent; wsc.OnMessage -= WscOnMessage;
wsc.Close();
base.OnDisappearing(); base.OnDisappearing();
} }
} }

View File

@@ -6,11 +6,10 @@ namespace RelayClient;
public static class MauiProgram public static class MauiProgram
{ {
// public static event Action<ChatMessage>? MessageSent; // public static event Action<ChatMessage>? MessageSent;
public static WebSocket wsc = new WebSocket("ws://localhost:1337");
public static MauiApp CreateMauiApp() public static MauiApp CreateMauiApp()
{ {
wsc.OnMessage += (sender, e) => OnWebSocketRecieved(sender, e); //wsc.OnMessage += (sender, e) => OnWebSocketRecieved(sender, e);
wsc.Connect(); //wsc.Connect();
var builder = MauiApp.CreateBuilder(); var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>().ConfigureFonts(fonts => builder.UseMauiApp<App>().ConfigureFonts(fonts =>
{ {
@@ -29,19 +28,18 @@ public static class MauiProgram
return builder.Build(); return builder.Build();
} }
public static void OnWebSocketRecieved(object? sender, MessageEventArgs e) //public static void OnWebSocketRecieved(object? sender, MessageEventArgs e)
{ //{
Console.WriteLine(sender.ToString()); // Console.WriteLine(sender.ToString());
//
ChatSimulator.Send(e.Data.Split(":")[0], e.Data.Split(":")[1]); // ChatSimulator.Send(e.Data.Split(":")[0], e.Data.Split(":")[1]);
// // var message = new ChatMessage
// var message = new ChatMessage // // {
// { // // SenderUsername = e.Data.Split(":")[0],
// SenderUsername = e.Data.Split(":")[0], // // Text = e.Data.Split(":")[1],
// Text = e.Data.Split(":")[1], // // Timestamp = DateTime.Now
// Timestamp = DateTime.Now // // };
// }; // //
// // // MessageSent?.Invoke(message);
// MessageSent?.Invoke(message); //}
}
} }

View File

@@ -0,0 +1,7 @@
namespace RelayClient.Models;
public class ServerPublicKeyMessage
{
public required string Type { get; set; }
public required string PublicKey { get; set; }
}

View File

@@ -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; }
}

View File

@@ -1,8 +1,6 @@
using SurrealDb.Net; using SurrealDb.Net;
using SurrealDb.Net.Models;
using SurrealDb.Net.Models.Auth; using SurrealDb.Net.Models.Auth;
using System.Text.Json; using System.Text.Json;
using RelayCore;
using RelayCore.Enums; using RelayCore.Enums;
using RelayCore.Models; using RelayCore.Models;
@@ -13,9 +11,11 @@ await db.Use("test", "test");
var keeper = await CreateUserAsync(db, "Keeper317", "Keeper317@gmail.com", "password"); var keeper = await CreateUserAsync(db, "Keeper317", "Keeper317@gmail.com", "password");
var kira = await CreateUserAsync(db, "Ru_Kira", "jduesling13@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($"Keeper created: {ToJsonString(keeper)}");
Console.WriteLine($"Kira created: {ToJsonString(kira)}"); Console.WriteLine($"Kira created: {ToJsonString(kira)}");
Console.WriteLine($"Test created: {ToJsonString(test)}");
Console.ReadKey(true); Console.ReadKey(true);
return; return;

View File

@@ -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; }
}

View File

@@ -5,6 +5,8 @@ namespace RelayServer.Models;
public class ServerEncryptionKeys : Record public class ServerEncryptionKeys : Record
{ {
public required string KeyBase64 { get; set; } 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 CreatedAt { get; set; }
public required DateTime UpdatedAt { get; set; } public required DateTime UpdatedAt { get; set; }
} }

View File

@@ -0,0 +1,7 @@
namespace RelayServer.Models;
public class ServerPublicKeyMessage
{
public required string Type { get; set; }
public required string PublicKey { get; set; }
}

View File

@@ -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; }
}

View File

@@ -1,10 +1,7 @@
using System.Text.Json; using System.Text.Json;
using System;
using WebSocketSharp.Server;
using WebSocketSharp;
using RelayServer.Models;
using RelayServer.Services; using RelayServer.Services;
using WebSocketSharp.Server;
using RelayServer.Models;
var surrealService = new SurrealService(); var surrealService = new SurrealService();
var coreClient = new CoreClientService(); var coreClient = new CoreClientService();
@@ -12,23 +9,25 @@ var cryptoService = new ChannelCryptoService();
await using var db = await surrealService.ConnectAsync(); await using var db = await surrealService.ConnectAsync();
ChatTest.ClientKeyService = new ClientKeyService(db);
ChatTest.Db = db;
var wssv = new WebSocketServer("ws://localhost:1337"); var wssv = new WebSocketServer("ws://localhost:1337");
wssv.AddWebSocketService<ChatTest>("/"); wssv.AddWebSocketService<ChatTest>("/");
wssv.Start(); wssv.Start();
Console.WriteLine("WebSocket server started"); Console.WriteLine("WebSocket server started");
Console.ReadKey(true);
wssv.Stop();
var keeper = await coreClient.GetUserByUsernameAsync("Keeper317"); var keeper = await coreClient.GetUserByUsernameAsync("Keeper317");
var kira = await coreClient.GetUserByUsernameAsync("Ru_Kira"); 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."); Console.WriteLine("One or more required users do not exist in RelayCore.");
return; return;
} }
if (!keeper.Licensed || !kira.Licensed) if (!keeper.Licensed || !kira.Licensed || !test.Licensed)
{ {
Console.WriteLine("One or more required users are not licensed."); Console.WriteLine("One or more required users are not licensed.");
return; return;
@@ -36,11 +35,12 @@ if (!keeper.Licensed || !kira.Licensed)
Console.WriteLine($"Core verified user: {keeper.Username}"); Console.WriteLine($"Core verified user: {keeper.Username}");
Console.WriteLine($"Core verified user: {kira.Username}"); Console.WriteLine($"Core verified user: {kira.Username}");
Console.WriteLine($"Core verified user: {test.Username}");
var server = await db.Create("servers", new Servers var server = await db.Create("servers", new Servers
{ {
Name = "Test Server", Name = "Test Server",
OwnerUserId = kira.Id, OwnerUserId = keeper.Id,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}); });
@@ -60,6 +60,13 @@ var kiraMember = await db.Create("server_members", new ServerMembers
IsOwner = false IsOwner = false
}); });
var testMember = await db.Create("server_members", new ServerMembers
{
UserId = test.Id,
JoinedAt = DateTime.UtcNow,
IsOwner = false
});
Console.WriteLine("Server members created."); Console.WriteLine("Server members created.");
var channel = await db.Create("channels", new Channels var channel = await db.Create("channels", new Channels
@@ -72,66 +79,29 @@ Console.WriteLine($"Channel created: {ToJsonString(channel)}");
var channelId = GetRecordId(channel.Id); var channelId = GetRecordId(channel.Id);
Console.WriteLine($"Resolved channelId: {channelId}"); Console.WriteLine($"Resolved channelId: {channelId}");
ChatTest.DefaultChannelId = channelId;
var keyBase64 = cryptoService.GenerateKey(); var keyBase64 = cryptoService.GenerateKey();
var serverKeys = E2EeHelper.GenerateRsaKeyPair();
var serverKey = await db.Create("server_encryption_keys", new ServerEncryptionKeys var serverKey = await db.Create("server_encryption_keys", new ServerEncryptionKeys
{ {
KeyBase64 = keyBase64, KeyBase64 = keyBase64,
PublicKey = serverKeys.publicKey,
PrivateKey = serverKeys.privateKey,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow UpdatedAt = DateTime.UtcNow
}); });
ChatTest.ServerPublicKey = serverKeys.publicKey;
ChatTest.ServerPrivateKey = serverKeys.privateKey;
ChatTest.ChannelDbKey = keyBase64;
Console.WriteLine("Server encryption key created."); 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<ChannelMessages>("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; return;
static string ToJsonString(object? obj) static string ToJsonString(object? obj)
@@ -158,15 +128,4 @@ static string GetRecordId(object? id)
var table = root.GetProperty("Table").GetString() ?? string.Empty; var table = root.GetProperty("Table").GetString() ?? string.Empty;
return $"{table}:{recordId}"; 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);
}
} }

View File

@@ -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<SocketEncryptedMessage>(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<ChannelMessages>("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));
}
}
}

View File

@@ -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<ClientPublicKeys>("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<ClientPublicKeys, ClientPublicKeys>(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<ClientPublicKeys?> GetByUsernameAsync(string username)
{
var allKeys = await _db.Select<ClientPublicKeys>("client_public_keys");
return allKeys.FirstOrDefault(x => x.Username == username);
}
public async Task<List<ClientPublicKeys>> GetAllAsync()
{
var allKeys = await _db.Select<ClientPublicKeys>("client_public_keys");
return allKeys.ToList();
}
}

View File

@@ -8,6 +8,7 @@ public sealed class CoreClientService
{ {
"Keeper317" => new CoreUser("users:keeper317", "Keeper317", true), "Keeper317" => new CoreUser("users:keeper317", "Keeper317", true),
"Ru_Kira" => new CoreUser("users:ru_kira", "Ru_Kira", true), "Ru_Kira" => new CoreUser("users:ru_kira", "Ru_Kira", true),
"Test" => new CoreUser("users:test", "Test", true),
_ => null _ => null
}); });
} }

View File

@@ -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; }
}

123
dev-run.ps1 Normal file
View File

@@ -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
}

85
start-all.ps1 Normal file
View File

@@ -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