Some orgnization, and cleanup to come.

This commit is contained in:
2026-04-02 13:36:54 -04:00
parent 6287f4d19b
commit a4f0175ca4
30 changed files with 285 additions and 83 deletions

View File

@@ -0,0 +1,44 @@
using System.Security.Cryptography;
using System.Text;
namespace RelayServer.Services;
public sealed class ChannelCryptoService
{
public string GenerateKey()
{
return Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
}
public (string cipherText, string nonce, string tag) Encrypt(string plainText, string keyBase64)
{
var key = Convert.FromBase64String(keyBase64);
var nonce = RandomNumberGenerator.GetBytes(12);
var plainBytes = Encoding.UTF8.GetBytes(plainText);
var cipherBytes = new byte[plainBytes.Length];
var tag = new byte[16];
using var aes = new AesGcm(key, 16);
aes.Encrypt(nonce, plainBytes, cipherBytes, tag);
return (
Convert.ToBase64String(cipherBytes),
Convert.ToBase64String(nonce),
Convert.ToBase64String(tag)
);
}
public string Decrypt(string cipherTextBase64, string nonceBase64, string tagBase64, string keyBase64)
{
var key = Convert.FromBase64String(keyBase64);
var nonce = Convert.FromBase64String(nonceBase64);
var tag = Convert.FromBase64String(tagBase64);
var cipherBytes = Convert.FromBase64String(cipherTextBase64);
var plainBytes = new byte[cipherBytes.Length];
using var aes = new AesGcm(key, 16);
aes.Decrypt(nonce, cipherBytes, tag, plainBytes);
return Encoding.UTF8.GetString(plainBytes);
}
}

View File

@@ -0,0 +1,6 @@
namespace RelayServer.Services;
public class ChannelMessageService
{
}

View File

@@ -0,0 +1,493 @@
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; }
private static readonly Dictionary<string, string> ActiveRtcOffersByChannel = new();
private static readonly HashSet<string> ActiveRtcChannels = new();
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 == "GET_CHANNELS")
{
HandleGetChannels();
return;
}
if (msg.StartsWith("GET_HISTORY|"))
{
HandleGetHistory(msg);
return;
}
SocketRtcSignalMessage? rtcProbe = null;
try
{
rtcProbe = JsonSerializer.Deserialize<SocketRtcSignalMessage>(msg);
}
catch
{
// ignored
}
if (rtcProbe?.Type == "encrypted_rtc_signal")
{
HandleEncryptedRtcSignal(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 HandleGetChannels()
{
if (Db is null)
{
Console.WriteLine("Db is not initialized.");
return;
}
var channels = Task.Run(async () => await Db.Select<Channels>("channels"))
.GetAwaiter()
.GetResult()
.OrderBy(c => c.CreatedAt)
.Select(c => new SocketChannelInfo
{
ChannelId = GetRecordId(c.Id),
Name = c.Name,
CreatedAt = c.CreatedAt
})
.ToList();
var payload = new SocketChannelList
{
Type = "channel_list",
Channels = channels
};
Send(JsonSerializer.Serialize(payload));
}
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))
{
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 = clientPayload.ChannelId,
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,
ChannelId = clientPayload.ChannelId,
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('|', 3);
if (parts.Length < 3)
{
Console.WriteLine("Invalid GET_HISTORY payload.");
return;
}
var username = parts[1];
var channelId = parts[2];
if (ClientKeyService is null ||
Db is null ||
string.IsNullOrWhiteSpace(ChannelDbKey))
{
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 == channelId)
.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,
ChannelId = channelId,
CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce,
Tag = encrypted.Tag,
EncryptedKey = encrypted.EncryptedKey
};
Send(JsonSerializer.Serialize(outbound));
}
}
private static string GetRecordId(object? id)
{
if (id is null)
return string.Empty;
var json = JsonSerializer.Serialize(id);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var recordId = root.GetProperty("Id").GetString() ?? string.Empty;
var table = root.GetProperty("Table").GetString() ?? string.Empty;
return $"{table}:{recordId}";
}
private void HandleEncryptedRtcSignal(string msg)
{
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())
.GetAwaiter()
.GetResult();
if (rtcSignal.Type == "rtc_join")
{
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;
}
if (rtcSignal.Type == "rtc_offer")
{
ActiveRtcOffersByChannel[rtcSignal.ChannelId] = plainJson;
ActiveRtcChannels.Add(rtcSignal.ChannelId);
}
if (rtcSignal.Type == "rtc_leave")
{
ActiveRtcOffersByChannel.Remove(rtcSignal.ChannelId);
ActiveRtcChannels.Remove(rtcSignal.ChannelId);
}
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));
}
}
}