Finished. Have at it.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace RelayServer.Services;
|
||||
namespace RelayServer.Services.Chat;
|
||||
|
||||
public sealed class ChannelCryptoService
|
||||
{
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace RelayServer.Services;
|
||||
|
||||
public class ChannelMessageService
|
||||
{
|
||||
|
||||
}
|
||||
@@ -1,20 +1,30 @@
|
||||
using System.Text.Json;
|
||||
using RelayServer.Models;
|
||||
using RelayServer.Services.Crypto;
|
||||
using RelayServer.Services.Data;
|
||||
using WebSocketSharp;
|
||||
using WebSocketSharp.Server;
|
||||
|
||||
namespace RelayServer.Services;
|
||||
namespace RelayServer.Services.Chat;
|
||||
|
||||
public class ChatTest : WebSocketBehavior
|
||||
/// <summary>
|
||||
/// Handles websocket-based chat operations including client key registration,
|
||||
/// server key retrieval, channel listing, channel history loading, and encrypted
|
||||
/// channel message relay.
|
||||
/// </summary>
|
||||
public class ChatSocketBehavior : 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 ChannelCryptoService? ChannelCryptoService { get; set; }
|
||||
public static SurrealDb.Net.SurrealDbClient? Db { get; set; }
|
||||
private static readonly Dictionary<string, string> ActiveRtcOffersByChannel = new();
|
||||
private static readonly HashSet<string> ActiveRtcChannels = new();
|
||||
|
||||
/// <summary>
|
||||
/// Routes incoming websocket messages to the appropriate chat handler.
|
||||
/// </summary>
|
||||
/// <param name="e">The websocket message event arguments.</param>
|
||||
protected override void OnMessage(MessageEventArgs e)
|
||||
{
|
||||
var msg = e.Data;
|
||||
@@ -44,25 +54,16 @@ public class ChatTest : WebSocketBehavior
|
||||
return;
|
||||
}
|
||||
|
||||
SocketRtcSignalMessage? rtcProbe = null;
|
||||
try
|
||||
{
|
||||
rtcProbe = JsonSerializer.Deserialize<SocketRtcSignalMessage>(msg);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
|
||||
if (rtcProbe?.Type == "encrypted_rtc_signal")
|
||||
{
|
||||
HandleEncryptedRtcSignal(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
HandleEncryptedClientMessage(msg);
|
||||
HandleEncryptedChatMessage(msg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a display username from a stored user record id value.
|
||||
/// </summary>
|
||||
/// <param name="senderUserId">The stored sender user id.</param>
|
||||
/// <returns>
|
||||
/// The extracted username when possible; otherwise, a fallback value.
|
||||
/// </returns>
|
||||
private static string ExtractUsernameFromUserId(string senderUserId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(senderUserId))
|
||||
@@ -72,6 +73,10 @@ public class ChatTest : WebSocketBehavior
|
||||
return parts.Length == 2 ? parts[1] : senderUserId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers or updates a client's public key from a websocket registration payload.
|
||||
/// </summary>
|
||||
/// <param name="msg">The raw websocket registration message.</param>
|
||||
private void HandleRegisterKey(string msg)
|
||||
{
|
||||
var parts = msg.Split('|', 3);
|
||||
@@ -91,12 +96,14 @@ public class ChatTest : WebSocketBehavior
|
||||
return;
|
||||
}
|
||||
|
||||
Task.Run(async () => { await ClientKeyService.RegisterOrUpdateKeyAsync(username, publicKey); }).GetAwaiter()
|
||||
.GetResult();
|
||||
RegisterOrUpdateClientKeySync(username, publicKey);
|
||||
|
||||
Send($"SERVER:REGISTERED_KEY:{username}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the current list of channels to the connected websocket client.
|
||||
/// </summary>
|
||||
private void HandleGetChannels()
|
||||
{
|
||||
if (Db is null)
|
||||
@@ -105,9 +112,7 @@ public class ChatTest : WebSocketBehavior
|
||||
return;
|
||||
}
|
||||
|
||||
var channels = Task.Run(async () => await Db.Select<Channels>("channels"))
|
||||
.GetAwaiter()
|
||||
.GetResult()
|
||||
var channels = GetChannelsSync()
|
||||
.OrderBy(c => c.CreatedAt)
|
||||
.Select(c => new SocketChannelInfo
|
||||
{
|
||||
@@ -126,6 +131,9 @@ public class ChatTest : WebSocketBehavior
|
||||
Send(JsonSerializer.Serialize(payload));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the server's public key to the connected websocket client.
|
||||
/// </summary>
|
||||
private void HandleGetServerKey()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ServerPublicKey))
|
||||
@@ -143,7 +151,12 @@ public class ChatTest : WebSocketBehavior
|
||||
Send(JsonSerializer.Serialize(payload));
|
||||
}
|
||||
|
||||
private void HandleEncryptedClientMessage(string msg)
|
||||
/// <summary>
|
||||
/// Decrypts an incoming encrypted chat payload, stores it in the database,
|
||||
/// and rebroadcasts it to connected clients encrypted with each client's public key.
|
||||
/// </summary>
|
||||
/// <param name="msg">The raw encrypted chat websocket message.</param>
|
||||
private void HandleEncryptedChatMessage(string msg)
|
||||
{
|
||||
SocketEncryptedMessage? clientPayload;
|
||||
|
||||
@@ -160,14 +173,8 @@ public class ChatTest : WebSocketBehavior
|
||||
if (clientPayload is null || clientPayload.Type != "client_encrypted_chat")
|
||||
return;
|
||||
|
||||
if (ClientKeyService is null ||
|
||||
Db is null ||
|
||||
string.IsNullOrWhiteSpace(ServerPrivateKey) ||
|
||||
string.IsNullOrWhiteSpace(ChannelDbKey))
|
||||
{
|
||||
Console.WriteLine("Server crypto/database dependencies are not initialized.");
|
||||
if (!EnsureCoreReady() || !EnsureCryptoReady())
|
||||
return;
|
||||
}
|
||||
|
||||
string plainText;
|
||||
|
||||
@@ -193,20 +200,17 @@ public class ChatTest : WebSocketBehavior
|
||||
Console.WriteLine($"Server decrypted message from {clientPayload.SenderUsername}: {plainText}");
|
||||
try
|
||||
{
|
||||
var channelCrypto = new ChannelCryptoService();
|
||||
var dbEncrypted = channelCrypto.Encrypt(plainText, ChannelDbKey);
|
||||
var dbEncrypted = ChannelCryptoService.Encrypt(plainText, ChannelDbKey);
|
||||
|
||||
var savedMessage = Task.Run(async () =>
|
||||
await Db.Create("channel_messages", new ChannelMessages
|
||||
{
|
||||
ChannelId = clientPayload.ChannelId,
|
||||
SenderUserId = $"users:{clientPayload.SenderUsername.ToLower()}",
|
||||
CipherText = dbEncrypted.cipherText,
|
||||
Nonce = dbEncrypted.nonce,
|
||||
Tag = dbEncrypted.tag,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
})
|
||||
).GetAwaiter().GetResult();
|
||||
var savedMessage = CreateChannelMessageSync(new ChannelMessages
|
||||
{
|
||||
ChannelId = clientPayload.ChannelId,
|
||||
SenderUserId = $"users:{clientPayload.SenderUsername.ToLower()}",
|
||||
CipherText = dbEncrypted.cipherText,
|
||||
Nonce = dbEncrypted.nonce,
|
||||
Tag = dbEncrypted.tag,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
Console.WriteLine($"Live message saved to DB: {JsonSerializer.Serialize(savedMessage)}");
|
||||
}
|
||||
@@ -216,9 +220,7 @@ public class ChatTest : WebSocketBehavior
|
||||
return;
|
||||
}
|
||||
|
||||
var allKeys = Task.Run(async () => await ClientKeyService.GetAllAsync())
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
var allKeys = GetAllClientPublicKeysSync();
|
||||
|
||||
foreach (var client in allKeys)
|
||||
{
|
||||
@@ -242,6 +244,11 @@ public class ChatTest : WebSocketBehavior
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads stored channel history for a specific user and channel, decrypts it from
|
||||
/// database storage format, and sends it back encrypted for the requesting client.
|
||||
/// </summary>
|
||||
/// <param name="msg">The raw history request websocket message.</param>
|
||||
private void HandleGetHistory(string msg)
|
||||
{
|
||||
var parts = msg.Split('|', 3);
|
||||
@@ -255,17 +262,13 @@ public class ChatTest : WebSocketBehavior
|
||||
var username = parts[1];
|
||||
var channelId = parts[2];
|
||||
|
||||
if (ClientKeyService is null ||
|
||||
Db is null ||
|
||||
string.IsNullOrWhiteSpace(ChannelDbKey))
|
||||
if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey))
|
||||
{
|
||||
Console.WriteLine("History dependencies are not initialized.");
|
||||
return;
|
||||
}
|
||||
|
||||
var targetClient = Task.Run(async () => await ClientKeyService.GetByUsernameAsync(username))
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
var targetClient = GetClientPublicKeyByUsernameSync(username);
|
||||
|
||||
if (targetClient is null)
|
||||
{
|
||||
@@ -273,9 +276,7 @@ public class ChatTest : WebSocketBehavior
|
||||
return;
|
||||
}
|
||||
|
||||
var allMessages = Task.Run(async () => await Db.Select<ChannelMessages>("channel_messages"))
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
var allMessages = GetChannelMessagesSync();
|
||||
|
||||
var channelMessages = allMessages
|
||||
.Where(m => m.ChannelId == channelId)
|
||||
@@ -284,15 +285,13 @@ public class ChatTest : WebSocketBehavior
|
||||
|
||||
Console.WriteLine($"Sending {channelMessages.Count} history messages to {username}");
|
||||
|
||||
var channelCrypto = new ChannelCryptoService();
|
||||
|
||||
foreach (var dbMessage in channelMessages)
|
||||
{
|
||||
string plainText;
|
||||
|
||||
try
|
||||
{
|
||||
plainText = channelCrypto.Decrypt(
|
||||
plainText = ChannelCryptoService.Decrypt(
|
||||
dbMessage.CipherText,
|
||||
dbMessage.Nonce,
|
||||
dbMessage.Tag,
|
||||
@@ -323,6 +322,13 @@ public class ChatTest : WebSocketBehavior
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a SurrealDB record id object into a table:id string representation.
|
||||
/// </summary>
|
||||
/// <param name="id">The raw record id object.</param>
|
||||
/// <returns>
|
||||
/// A formatted record id string, or an empty string if the input is null.
|
||||
/// </returns>
|
||||
private static string GetRecordId(object? id)
|
||||
{
|
||||
if (id is null)
|
||||
@@ -339,155 +345,105 @@ public class ChatTest : WebSocketBehavior
|
||||
|
||||
return $"{table}:{recordId}";
|
||||
}
|
||||
|
||||
private void HandleEncryptedRtcSignal(string msg)
|
||||
|
||||
/// <summary>
|
||||
/// Synchronously registers or updates a stored client public key using the async key service.
|
||||
/// </summary>
|
||||
/// <param name="username">The client username.</param>
|
||||
/// <param name="publicKey">The client's public key.</param>
|
||||
private void RegisterOrUpdateClientKeySync(string username, string publicKey)
|
||||
{
|
||||
SocketRtcSignalMessage? clientPayload;
|
||||
|
||||
try
|
||||
{
|
||||
clientPayload = JsonSerializer.Deserialize<SocketRtcSignalMessage>(msg);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.WriteLine("Failed to parse encrypted RTC signal payload.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientPayload is null || clientPayload.Type != "encrypted_rtc_signal")
|
||||
return;
|
||||
|
||||
if (ClientKeyService is null || string.IsNullOrWhiteSpace(ServerPrivateKey))
|
||||
{
|
||||
Console.WriteLine("Server RTC crypto dependencies are not initialized.");
|
||||
return;
|
||||
}
|
||||
|
||||
string plainJson;
|
||||
|
||||
try
|
||||
{
|
||||
plainJson = 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 RTC signal payload: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
RtcSignalMessage? rtcSignal;
|
||||
|
||||
try
|
||||
{
|
||||
rtcSignal = JsonSerializer.Deserialize<RtcSignalMessage>(plainJson);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to parse decrypted RTC signal JSON: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (rtcSignal is null)
|
||||
return;
|
||||
|
||||
var allKeys = Task.Run(async () => await ClientKeyService.GetAllAsync())
|
||||
Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey))
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
|
||||
if (rtcSignal.Type == "rtc_join")
|
||||
/// <summary>
|
||||
/// Synchronously loads all channels from the database.
|
||||
/// </summary>
|
||||
/// <returns>A list of channel records.</returns>
|
||||
private List<Channels> GetChannelsSync()
|
||||
{
|
||||
return Task.Run(async () => await Db!.Select<Channels>("channels"))
|
||||
.GetAwaiter()
|
||||
.GetResult()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronously gets the stored public key record for the specified user.
|
||||
/// </summary>
|
||||
/// <param name="username">The username to look up.</param>
|
||||
/// <returns>
|
||||
/// The matching client public key record, or null if none exists.
|
||||
/// </returns>
|
||||
private ClientPublicKeys? GetClientPublicKeyByUsernameSync(string username)
|
||||
{
|
||||
return Task.Run(async () => await ClientKeyService!.GetByUsernameAsync(username))
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronously loads all stored client public key records.
|
||||
/// </summary>
|
||||
/// <returns>A list of all client public key records.</returns>
|
||||
private List<ClientPublicKeys> GetAllClientPublicKeysSync()
|
||||
{
|
||||
return Task.Run(async () => await ClientKeyService!.GetAllAsync())
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronously loads all stored channel messages from the database.
|
||||
/// </summary>
|
||||
/// <returns>A list of channel message records.</returns>
|
||||
private List<ChannelMessages> GetChannelMessagesSync()
|
||||
{
|
||||
return Task.Run(async () => await Db!.Select<ChannelMessages>("channel_messages"))
|
||||
.GetAwaiter()
|
||||
.GetResult()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronously creates a new channel message record in the database.
|
||||
/// </summary>
|
||||
/// <param name="message">The message record to create.</param>
|
||||
/// <returns>The created channel message record.</returns>
|
||||
private ChannelMessages CreateChannelMessageSync(ChannelMessages message)
|
||||
{
|
||||
return Task.Run(async () => await Db!.Create("channel_messages", message))
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
|
||||
private bool EnsureCoreReady()
|
||||
{
|
||||
if (ClientKeyService is null || Db is null)
|
||||
{
|
||||
var joinState = new
|
||||
{
|
||||
type = "rtc_join_state",
|
||||
from = "server",
|
||||
channelId = rtcSignal.ChannelId,
|
||||
isInitiator = !ActiveRtcOffersByChannel.ContainsKey(rtcSignal.ChannelId)
|
||||
};
|
||||
|
||||
var senderClient = allKeys.FirstOrDefault(x => x.Username == clientPayload.SenderUsername);
|
||||
if (senderClient is null)
|
||||
{
|
||||
Console.WriteLine($"No client key found for RTC join sender {clientPayload.SenderUsername}");
|
||||
return;
|
||||
}
|
||||
|
||||
var joinStateJson = JsonSerializer.Serialize(joinState);
|
||||
var encryptedJoinState = E2EeHelper.EncryptForRecipient(joinStateJson, senderClient.PublicKey);
|
||||
|
||||
var joinStateOutbound = new SocketRtcSignalMessage
|
||||
{
|
||||
Type = "encrypted_rtc_signal",
|
||||
SenderUsername = "server",
|
||||
ChannelId = clientPayload.ChannelId,
|
||||
CipherText = encryptedJoinState.CipherText,
|
||||
Nonce = encryptedJoinState.Nonce,
|
||||
Tag = encryptedJoinState.Tag,
|
||||
EncryptedKey = encryptedJoinState.EncryptedKey
|
||||
};
|
||||
|
||||
Send(JsonSerializer.Serialize(joinStateOutbound));
|
||||
|
||||
if (ActiveRtcOffersByChannel.TryGetValue(rtcSignal.ChannelId, out var storedOfferJson))
|
||||
{
|
||||
var encryptedStoredOffer = E2EeHelper.EncryptForRecipient(storedOfferJson, senderClient.PublicKey);
|
||||
|
||||
var storedOfferOutbound = new SocketRtcSignalMessage
|
||||
{
|
||||
Type = "encrypted_rtc_signal",
|
||||
SenderUsername = "server",
|
||||
ChannelId = clientPayload.ChannelId,
|
||||
CipherText = encryptedStoredOffer.CipherText,
|
||||
Nonce = encryptedStoredOffer.Nonce,
|
||||
Tag = encryptedStoredOffer.Tag,
|
||||
EncryptedKey = encryptedStoredOffer.EncryptedKey
|
||||
};
|
||||
|
||||
Send(JsonSerializer.Serialize(storedOfferOutbound));
|
||||
}
|
||||
|
||||
return;
|
||||
Console.WriteLine("Core services not initialized.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rtcSignal.Type == "rtc_offer")
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool EnsureCryptoReady()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey))
|
||||
{
|
||||
ActiveRtcOffersByChannel[rtcSignal.ChannelId] = plainJson;
|
||||
ActiveRtcChannels.Add(rtcSignal.ChannelId);
|
||||
Console.WriteLine("Crypto keys not initialized.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rtcSignal.Type == "rtc_leave")
|
||||
if (ChannelCryptoService is null)
|
||||
{
|
||||
ActiveRtcOffersByChannel.Remove(rtcSignal.ChannelId);
|
||||
ActiveRtcChannels.Remove(rtcSignal.ChannelId);
|
||||
Console.WriteLine("ChannelCryptoService is not initialized.");
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var client in allKeys)
|
||||
{
|
||||
if (client.Username == clientPayload.SenderUsername)
|
||||
continue;
|
||||
|
||||
var encrypted = E2EeHelper.EncryptForRecipient(plainJson, client.PublicKey);
|
||||
|
||||
var outbound = new SocketRtcSignalMessage
|
||||
{
|
||||
Type = "encrypted_rtc_signal",
|
||||
SenderUsername = clientPayload.SenderUsername,
|
||||
ChannelId = clientPayload.ChannelId,
|
||||
CipherText = encrypted.CipherText,
|
||||
Nonce = encrypted.Nonce,
|
||||
Tag = encrypted.Tag,
|
||||
EncryptedKey = encrypted.EncryptedKey
|
||||
};
|
||||
|
||||
Sessions.Broadcast(JsonSerializer.Serialize(outbound));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user