15 Commits

22 changed files with 1042 additions and 436 deletions

View File

@@ -15,7 +15,8 @@ public partial class App : Application
if (string.IsNullOrWhiteSpace(username)) if (string.IsNullOrWhiteSpace(username))
{ {
throw new Exception("Missing required --user argument. Example: --user Keeper317"); username = "Test";
// throw new Exception("Missing required --user argument. Example: --user Keeper317");
} }
ClientSession.Username = username; ClientSession.Username = username;

View File

@@ -4,7 +4,8 @@
xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:RelayClient" xmlns:local="clr-namespace:RelayClient"
Title="RelayClient"> Title="RelayClient"
FlyoutBehavior="Flyout">
<ShellContent <ShellContent
Title="Home" Title="Home"

View File

@@ -8,10 +8,12 @@ namespace RelayClient;
public partial class MainPage : ContentPage public partial class MainPage : ContentPage
{ {
private readonly string _username; public static string _username;
private readonly RelaySocketClient _socket; private readonly RelaySocketClient _socket;
private readonly RtcBridgeService _rtc; private readonly RtcBridgeService _rtc;
public static string? _userToken;
private string? _currentChannelId; private string? _currentChannelId;
private string? _currentChannelName; private string? _currentChannelName;
@@ -23,6 +25,7 @@ public partial class MainPage : ContentPage
InitializeComponent(); InitializeComponent();
_username = username; _username = username;
UserLabel.Text = $"Logged in as: {_username}"; UserLabel.Text = $"Logged in as: {_username}";
if (!KeyStorage.HasKeys(_username)) if (!KeyStorage.HasKeys(_username))
@@ -32,7 +35,7 @@ public partial class MainPage : ContentPage
KeyStorage.SavePublicKey(_username, keys.publicKey); KeyStorage.SavePublicKey(_username, keys.publicKey);
} }
ServerAPI.setupClient(); var waitFor = ServerAPI.setupClient();
_socket = new RelaySocketClient(_username); _socket = new RelaySocketClient(_username);
_rtc = new RtcBridgeService( _rtc = new RtcBridgeService(
@@ -56,6 +59,8 @@ public partial class MainPage : ContentPage
}); });
}; };
// while(!waitFor.IsCompleted){}
_socket.Connect(); _socket.Connect();
} }
@@ -132,11 +137,11 @@ public partial class MainPage : ContentPage
await _rtc.PushRtcContextToJsAsync(); await _rtc.PushRtcContextToJsAsync();
}); });
_socket.SendRaw($"GET_HISTORY|{_username}|{_currentChannelId}"); _socket.SendGetHistory(_currentChannelId);
} }
private void HandleEncryptedChat(SocketEncryptedMessage payload) { private void HandleEncryptedChat(SocketEncryptedMessage payload) {
if (payload.RecipientUsername != _username) if (!payload.RecipientUsername.Equals(_username, StringComparison.OrdinalIgnoreCase))
return; return;
string decryptedText; string decryptedText;
@@ -224,7 +229,7 @@ public partial class MainPage : ContentPage
RenderCurrentChannelMessages(); RenderCurrentChannelMessages();
if (!_messagesByChannel.ContainsKey(channel.ChannelId)) if (!_messagesByChannel.ContainsKey(channel.ChannelId))
_socket.SendRaw($"GET_HISTORY|{_username}|{channel.ChannelId}"); _socket.SendGetHistory(channel.ChannelId);
}; };
SidebarList.Children.Add(button); SidebarList.Children.Add(button);

View File

@@ -50,3 +50,24 @@ window.addEventListener("load", async () => {
await Media.loadDevices(); await Media.loadDevices();
await Media.ensureLocalMedia(); await Media.ensureLocalMedia();
}); });
function testIndex(rawJson)
{
const data = typeof rawJson === "string" ? JSON.parse(rawJson) : rawJson;
if (data.sdp) {
data.sdp = data.sdp.replaceAll("(rn)", "\r\n");
}
handleRtcSignal(JSON.stringify(data));
// if (data.type === "rtc_offer") {
// handleOffer(data)
// }
// if (data.type === "rtc_answer") {
// data.sdp = data.sdp.replaceAll("(rn)", "\r\n");
// handleAnswer(data)
// }
}
function noDataTest()
{
LogMessage("No Data Called!!");
}

View File

@@ -1,4 +1,4 @@
const peerConnections = {}; const peerConnections = {};
async function joinChannelCall() { async function joinChannelCall() {
LogMessage("Current username: " + currentUsername); LogMessage("Current username: " + currentUsername);
@@ -24,7 +24,7 @@ async function joinChannelCall() {
} }
for (const username of existingUsers) { for (const username of existingUsers) {
await sendOffer(username); await sendOffer(username); //Creates an offer to each person in call for MESH RTC
} }
} }
@@ -34,6 +34,7 @@ async function sendOffer(username) {
await Media.applyLocalStreamToPeerConnection(pc, username); await Media.applyLocalStreamToPeerConnection(pc, username);
const offer = await pc.createOffer(); const offer = await pc.createOffer();
// LogMessage(`Offer created: ${JSON.stringify(offer)}`);
await pc.setLocalDescription(offer); await pc.setLocalDescription(offer);
await RelaySocket.sendRtcSignal({ await RelaySocket.sendRtcSignal({
@@ -88,11 +89,12 @@ async function handleRtcSignal(rawJson) {
} }
async function handleOffer(msg) { async function handleOffer(msg) {
LogMessage(`Offer handler: ${msg}`);
const pc = await ensurePeerConnectionForUser(msg.from); const pc = await ensurePeerConnectionForUser(msg.from);
await Media.ensureLocalMedia(); await Media.ensureLocalMedia();
await Media.applyLocalStreamToPeerConnection(pc, msg.from); await Media.applyLocalStreamToPeerConnection(pc, msg.from);
// const offer = JSON.parse(msg.offer);
await pc.setRemoteDescription({ await pc.setRemoteDescription({
type: "offer", type: "offer",
sdp: msg.sdp sdp: msg.sdp
@@ -138,7 +140,13 @@ async function handleIce(msg) {
if (!msg.candidate) return; if (!msg.candidate) return;
await pc.addIceCandidate(msg.candidate); const candidateInit = {
candidate: msg.candidate,
sdpMid: msg.sdpMid,
sdpMLineIndex: msg.sdpMLineIndex
};
await pc.addIceCandidate(candidateInit);
LogMessage(`Applied ICE from ${msg.from}`); LogMessage(`Applied ICE from ${msg.from}`);
} }
@@ -159,7 +167,9 @@ async function ensurePeerConnectionForUser(username) {
channelId: currentChannelId, channelId: currentChannelId,
from: currentUsername, from: currentUsername,
to: username, to: username,
candidate: JSON.stringify(event.candidate) candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex
}); });
}; };

View File

@@ -1,18 +1,51 @@
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using RelayShared.Services;
namespace RelayClient; namespace RelayClient;
public class ServerAPI public class ServerAPI
{ {
static HttpClient client = new HttpClient { BaseAddress = new Uri("http://localhost:5000/") }; static HttpClient client = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5000/") };
static HttpClient core = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:1337/") };
// static HttpClient client = new HttpClient { BaseAddress = new Uri("http://192.168.1.92:5000/") };
// static HttpClient core = new HttpClient { BaseAddress = new Uri("http://192.168.1.92:1337/") };
public static void setupClient() public static async Task setupClient()
{ {
client.DefaultRequestHeaders.Accept.Clear(); client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add( client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json")); new MediaTypeWithQualityHeaderValue("application/json"));
core.DefaultRequestHeaders.Accept.Clear();
core.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
core.DefaultRequestHeaders.Add("User-Agent", "RelayClient");
MainPage._userToken = await CoreUserSignin(new AuthSignin
{
UserName = MainPage._username,
Password = "password"
});
await CoreUserAlive(new AuthSignin
{
UserName = MainPage._username,
Password = MainPage._userToken
});
}
public static async Task<Uri> CoreUserAlive(AuthSignin data)
{
HttpResponseMessage response = await core.PostAsJsonAsync("user/isAlive", data);
response.EnsureSuccessStatusCode();
return response.Headers.Location;
}
public static async Task<string> CoreUserSignin(AuthSignin data)
{
HttpResponseMessage response = await core.PostAsJsonAsync("user/signin", data);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
} }
public static async Task<Uri> PostOfferAsync(DBOffer offer) public static async Task<Uri> PostOfferAsync(DBOffer offer)

View File

@@ -1,4 +1,4 @@
using System.Text.Json; using System.Text.Json;
using RelayClient.Crypto; using RelayClient.Crypto;
using RelayShared.Services; using RelayShared.Services;
using WebSocketSharp; using WebSocketSharp;
@@ -19,7 +19,7 @@ public sealed class RelaySocketClient
public event Action<string>? ServerPublicKeyReceived; public event Action<string>? ServerPublicKeyReceived;
public event Action<string>? Log; public event Action<string>? Log;
public RelaySocketClient(string username, string url = "ws://localhost:1337/") public RelaySocketClient(string username, string url = "ws://127.0.0.1:5001/")
{ {
_username = username; _username = username;
_socket = new WebSocket(url); _socket = new WebSocket(url);
@@ -32,9 +32,57 @@ public sealed class RelaySocketClient
var publicKey = KeyStorage.LoadPublicKey(_username); var publicKey = KeyStorage.LoadPublicKey(_username);
SendRaw($"REGISTER_KEY|{_username}|{publicKey}"); SendControlMessage(new WsControlMessage
SendRaw("GET_SERVER_KEY"); {
SendRaw("GET_CHANNELS"); Action = WsAction.Authenticate,
Username = _username,
Token = MainPage._userToken
});
SendControlMessage(new WsControlMessage
{
Action = WsAction.RegisterKey,
Username = _username,
PublicKey = publicKey
});
SendControlMessage(new WsControlMessage { Action = WsAction.GetServerKey });
SendControlMessage(new WsControlMessage { Action = WsAction.GetChannels });
}
public void SendControlMessage(WsControlMessage message)
{
SendRaw(JsonSerializer.Serialize(message));
}
public void SendGetHistory(string channelId)
{
SendControlMessage(new WsControlMessage
{
Action = WsAction.GetHistory,
Username = _username,
ChannelId = channelId
});
}
public void SendRtcJoinChannel(string channelId)
{
SendControlMessage(new WsControlMessage
{
Action = WsAction.RtcJoin,
Username = _username,
ChannelId = channelId
});
}
public void SendRtcLeaveChannel(string channelId)
{
SendControlMessage(new WsControlMessage
{
Action = WsAction.RtcLeave,
Username = _username,
ChannelId = channelId
});
} }
public void SendRaw(string message) public void SendRaw(string message)
@@ -58,12 +106,6 @@ public sealed class RelaySocketClient
private void OnMessage(object? sender, MessageEventArgs e) private void OnMessage(object? sender, MessageEventArgs e)
{ {
if (e.Data.StartsWith("SERVER:REGISTERED_KEY:"))
{
Log?.Invoke(e.Data);
return;
}
RawMessageReceived?.Invoke(e.Data); RawMessageReceived?.Invoke(e.Data);
Log?.Invoke($"[{_username}] RAW WS DATA: {e.Data}"); Log?.Invoke($"[{_username}] RAW WS DATA: {e.Data}");
@@ -72,6 +114,31 @@ public sealed class RelaySocketClient
using var doc = JsonDocument.Parse(e.Data); using var doc = JsonDocument.Parse(e.Data);
var root = doc.RootElement; var root = doc.RootElement;
// Control event responses (WsEvent)
if (root.TryGetProperty("Event", out var eventElement))
{
var wsEvent = (WsEvent)eventElement.GetInt32();
switch (wsEvent)
{
case WsEvent.KeyRegistered:
Log?.Invoke($"[{_username}] Key registered on server.");
return;
case WsEvent.Authenticated:
Log?.Invoke($"[{_username}] Authenticated with server.");
return;
case WsEvent.Error:
var detail = root.TryGetProperty("Detail", out var d) ? d.GetString() : null;
Log?.Invoke($"[{_username}] Server error: {detail}");
return;
}
return;
}
// Data messages (SignalType)
if (!root.TryGetProperty("Type", out var typeElement)) if (!root.TryGetProperty("Type", out var typeElement))
return; return;

View File

@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
using RelayClient.Crypto; using RelayClient.Crypto;
using RelayShared.Rtc; using RelayShared.Rtc;
using RelayShared.Services; using RelayShared.Services;
@@ -30,7 +31,7 @@ public sealed class RtcBridgeService
if (string.IsNullOrWhiteSpace(channelId)) if (string.IsNullOrWhiteSpace(channelId))
return Task.CompletedTask; return Task.CompletedTask;
_socket.SendRaw($"RTC_JOIN_CHANNEL|{_username}|{channelId}"); _socket.SendRtcJoinChannel(channelId);
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -41,7 +42,7 @@ public sealed class RtcBridgeService
if (string.IsNullOrWhiteSpace(channelId)) if (string.IsNullOrWhiteSpace(channelId))
return; return;
_socket.SendRaw($"RTC_LEAVE_CHANNEL|{_username}|{channelId}"); _socket.SendRtcLeaveChannel(channelId);
} }
public void SendRtcSignal(string json) public void SendRtcSignal(string json)
@@ -70,6 +71,7 @@ public sealed class RtcBridgeService
rtcSignal.ChannelId ??= _getCurrentChannelId(); rtcSignal.ChannelId ??= _getCurrentChannelId();
rtcSignal.From ??= _username; rtcSignal.From ??= _username;
// _sendRawToWebView($"RTC_SIGNAL file: {JsonSerializer.Serialize(rtcSignal)}");
if (string.IsNullOrWhiteSpace(rtcSignal.ChannelId)) if (string.IsNullOrWhiteSpace(rtcSignal.ChannelId))
{ {
_sendRawToWebView("SendRtcSignal failed: missing channel id."); _sendRawToWebView("SendRtcSignal failed: missing channel id.");
@@ -116,13 +118,20 @@ public sealed class RtcBridgeService
public async Task HandleIncomingRtcSignalAsync(SocketRtcSignalMessage payload) public async Task HandleIncomingRtcSignalAsync(SocketRtcSignalMessage payload)
{ {
// _sendRawToWebView("HandleIncomingRtcSignal called");
var currentChannelId = _getCurrentChannelId(); var currentChannelId = _getCurrentChannelId();
if (payload.ChannelId != currentChannelId) if (payload.ChannelId != currentChannelId)
{
_sendRawToWebView("Channel id does not match");
return; return;
}
if (payload.SenderUsername == _username) if (payload.SenderUsername == _username)
{
_sendRawToWebView("Received own message");
return; return;
}
string decryptedJson; string decryptedJson;
@@ -152,6 +161,7 @@ public sealed class RtcBridgeService
try try
{ {
rtcSignal = JsonSerializer.Deserialize<RtcSignalMessage>(decryptedJson); rtcSignal = JsonSerializer.Deserialize<RtcSignalMessage>(decryptedJson);
// _sendRawToWebView($"Received Encrypted Signal: [{rtcSignal.From}]: {rtcSignal.Offer}");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -160,7 +170,10 @@ public sealed class RtcBridgeService
} }
if (rtcSignal is null) if (rtcSignal is null)
{
_sendRawToWebView("rtcSignal is null");
return; return;
}
if (!string.IsNullOrWhiteSpace(rtcSignal.To) && if (!string.IsNullOrWhiteSpace(rtcSignal.To) &&
!string.Equals(rtcSignal.To, _username, StringComparison.OrdinalIgnoreCase)) !string.Equals(rtcSignal.To, _username, StringComparison.OrdinalIgnoreCase))
@@ -169,9 +182,9 @@ public sealed class RtcBridgeService
return; return;
} }
_sendRawToWebView("Received encrypted RTC signal: " + decryptedJson); // _sendRawToWebView("Received encrypted RTC signal: " + decryptedJson);
await SendRtcSignalToJsAsync(decryptedJson); await SendRtcSignalToJsAsync(rtcSignal);
} }
public Task PushRtcContextToJsAsync() public Task PushRtcContextToJsAsync()
@@ -188,37 +201,55 @@ public sealed class RtcBridgeService
return Task.CompletedTask; return Task.CompletedTask;
} }
private Task SendRtcSignalToJsAsync(string rawJson) private Task SendRtcSignalToJsAsync(RtcSignalMessage data)
{ {
if (data.Type == "rtc_offer" || data.Type == "rtc_answer")
{
data.Sdp = data.Sdp.Replace("\r\n", "(rn)");
}
MainThread.BeginInvokeOnMainThread(async () => MainThread.BeginInvokeOnMainThread(async () =>
{ {
try try
{ {
var jsArg = JsonSerializer.Serialize(rawJson); // await _hybridWebView.InvokeJavaScriptAsync("testIndex", [JsonSerializer.Serialize(data)], [RtcJsType.Default.String]);
await _hybridWebView.InvokeJavaScriptAsync("testIndex", [data], [RtcJsType.Default.RtcSignalMessage]);
await _hybridWebView.EvaluateJavaScriptAsync($@" #region OldDebugger
try {{ // var jsArg = JsonSerializer.Serialize(data);
window.HybridWebView.SendRawMessage('C# eval entered'); //
// await _hybridWebView.EvaluateJavaScriptAsync($@"
if (!window.RelaySocket) {{ // try {{
window.HybridWebView.SendRawMessage('window.RelaySocket missing'); // window.HybridWebView.SendRawMessage('C# eval entered');
}} else if (typeof window.RelaySocket.receiveRtcSignal !== 'function') {{ //
window.HybridWebView.SendRawMessage('RelaySocket.receiveRtcSignal missing'); // if (!window.RelaySocket) {{
}} else {{ // window.HybridWebView.SendRawMessage('window.RelaySocket missing');
window.HybridWebView.SendRawMessage('Calling RelaySocket.receiveRtcSignal'); // }} else if (typeof window.RelaySocket.receiveRtcSignal !== 'function') {{
window.RelaySocket.receiveRtcSignal({jsArg}); // window.HybridWebView.SendRawMessage('RelaySocket.receiveRtcSignal missing');
}} // }} else {{
}} catch (err) {{ // window.HybridWebView.SendRawMessage('Calling RelaySocket.receiveRtcSignal');
window.HybridWebView.SendRawMessage('RTC JS dispatch failed: ' + err); // window.RelaySocket.receiveRtcSignal({jsArg});
}} // }}
"); // }} catch (err) {{
// window.HybridWebView.SendRawMessage('RTC JS dispatch failed: ' + err);
// }}
// ");
#endregion
} }
catch (Exception ex) catch (Exception ex)
{ {
_sendRawToWebView("SendRtcSignalToJsAsync failed: " + ex.Message); _sendRawToWebView("SendRtcSignalToJsAsync failed: " + ex.Message);
} }
}); });
return Task.CompletedTask; return Task.CompletedTask;
} }
} }
[JsonSourceGenerationOptions(WriteIndented = false)]
[JsonSerializable(typeof(RtcDescription))]
[JsonSerializable(typeof(List<RtcSignalMessage>))]
[JsonSerializable(typeof(RtcSignalMessage))]
[JsonSerializable(typeof(IceCandidate))]
[JsonSerializable(typeof(List<IceCandidate>))]
[JsonSerializable(typeof(string))]
internal partial class RtcJsType : JsonSerializerContext
{
}

View File

@@ -0,0 +1,63 @@
using Microsoft.Extensions.Primitives;
using RelayCore.Services;
using RelayShared.Services;
namespace RelayCore.Endpoints;
public static class AuthEndpoints
{
public static void MapAuthEndpoints(this WebApplication app)
{
app.MapPost("/user/signin", async (AuthSignin request, APIAuthService service, HttpContext context) =>
{
string ip = "";
StringValues userAgent = "";
if (context != null)
{
ip = context.Connection.RemoteIpAddress?.MapToIPv4().ToString();
context.Request.Headers.TryGetValue("User-Agent", out userAgent);
}
var token = await service.UserSigninAsync(request, ip, userAgent.ToString());
return token != null ? Results.Ok(token) : Results.Unauthorized();
});
app.MapGet("/users", async (APIAuthService service) =>
{
return Results.Ok(await service.GetUsersAsync());
});
app.MapPost("/user/register", async (AuthRegister request, APIAuthService service, HttpContext context) =>
{
var ip = context.Connection.RemoteIpAddress?.MapToIPv4().ToString();
context.Request.Headers.TryGetValue("User-Agent", out var userAgent);
var token = await service.UserRegisterAsync(request, ip, userAgent);
return token != null ? Results.Ok(token) : Results.Ok("Username or Email already exists!");
});
app.MapPost("/user/isAlive", async (AuthSignin request, HttpContext context) =>
{
var ip = context.Connection.RemoteIpAddress?.MapToIPv4().ToString();
context.Request.Headers.TryGetValue("User-Agent", out var userAgent);
Console.WriteLine($"UN: {request.UserName}\nToken: {request.Password}\nIP: {ip}\nUserAgent: {userAgent}");
return Results.Ok();
});
app.MapPost("/server/verify/user", async (AuthUserVerify request, APIAuthService service) =>
{
bool valid = await service.ServerVerifyUser(request);
Console.WriteLine($"UN: {request.Username}\nToken: {request.Token}");
return Results.Ok(valid);
});
app.MapPost("/server/license/generate", async (AuthServerLicenseGenerate request, APIAuthService service) =>
{
var license = await service.ServerLicenseGenerate(request);
return license != null ? Results.Ok(license) : Results.BadRequest();
});
app.MapPost("/server/license/verify", async (AuthServerLicenseVerify request, APIAuthService service) =>
{
bool valid = await service.ServerVerifyLicense(request);
return Results.Ok(valid);
});
}
}

View File

@@ -22,7 +22,7 @@ namespace RelayCore.Models
/// <summary> /// <summary>
/// Number of threads to use for parallel computation /// Number of threads to use for parallel computation
/// </summary> /// </summary>
private const int DegreeOfParallelism = 1; private const int DegreeOfParallelism = 2;
/// <summary> /// <summary>
/// Number of iterations for the Argon2id algorithm /// Number of iterations for the Argon2id algorithm

View File

@@ -4,7 +4,7 @@ namespace RelayCore.Models;
public class Sessions : Record public class Sessions : Record
{ {
public required string UserId { get; set; } public required RecordId UserId { get; set; }
public required string TokenHash { get; set; } public required string TokenHash { get; set; }
public required DateTime IssuedAt { get; set; } public required DateTime IssuedAt { get; set; }
public required DateTime ExpiresAt { get; set; } public required DateTime ExpiresAt { get; set; }

View File

@@ -1,3 +1,4 @@
using System.Text.Json.Serialization;
using SurrealDb.Net.Models; using SurrealDb.Net.Models;
namespace RelayCore.Models; namespace RelayCore.Models;

View File

@@ -1,14 +1,13 @@
using SurrealDb.Net; using SurrealDb.Net;
using SurrealDb.Net.Models.Auth; using SurrealDb.Net.Models.Auth;
using System.Text.Json; using System.Text.Json;
using System;
using System.Net; using System.Net;
using System.Threading.Tasks;
using System.Text; using System.Text;
using System.Text.Json;
using RelayCore.Enums; using RelayCore.Enums;
using RelayCore.Models; using RelayCore.Models;
using RelayCore.Endpoints;
using RelayCore.Services;
await using var db = new SurrealDbClient("ws://127.0.0.1:8000/rpc"); await using var db = new SurrealDbClient("ws://127.0.0.1:8000/rpc");
@@ -25,8 +24,26 @@ 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.WriteLine($"Test created: {ToJsonString(test)}");
await server.Main(db); var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://127.0.0.1:1337/");
// builder.WebHost.UseUrls("http://192.168.1.92:1337");
builder.Services.AddSingleton(db);
builder.Services.AddScoped<APIAuthService>();
var app = builder.Build();
app.MapGet("/", () => "Auth Server Running!");
app.MapAuthEndpoints();
// await server.Main(db);
await app.StartAsync();
Console.WriteLine("API Started");
Console.WriteLine("\n\n\n");
Console.Write("Press any key to stop.");
Console.ReadKey(true); Console.ReadKey(true);
await app.StopAsync();
return; return;
static string ToJsonString(object? o) static string ToJsonString(object? o)
@@ -51,7 +68,7 @@ static async Task<Users> CreateUserAsync(SurrealDbClient db, string username, st
OnlineStatus = (int)OnlineStatuses.Online, OnlineStatus = (int)OnlineStatuses.Online,
}; };
var created = await db.Create("users", user); var created = await db.Create("auth_users", user);
var hasher = new PasswordHasher(); var hasher = new PasswordHasher();
var passwordHash = hasher.HashPassword(created.Id.ToString() + rawPassword); var passwordHash = hasher.HashPassword(created.Id.ToString() + rawPassword);
@@ -65,16 +82,15 @@ static async Task<Users> CreateUserAsync(SurrealDbClient db, string username, st
return updated; return updated;
} }
partial class Program partial class Program
{ {
public async Task Main(SurrealDbClient db) public async Task Main(SurrealDbClient db)
{ {
// Set up listener // Set up listener
using var listener = new HttpListener(); using var listener = new HttpListener();
listener.Prefixes.Add("http://localhost:8080/"); listener.Prefixes.Add("http://127.0.0.1:8080/");
listener.Start(); listener.Start();
Console.WriteLine("API Started: http://localhost:8080/"); Console.WriteLine("API Started: http://127.0.0.1:8080/");
while (true) while (true)
{ {

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
@@ -10,11 +10,12 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" /> <PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.9" />
<PackageReference Include="SurrealDb.Net" Version="0.9.0" /> <PackageReference Include="SurrealDb.Net" Version="0.9.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Services\" /> <ProjectReference Include="..\RelayShared\RelayShared.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,126 @@
using Microsoft.AspNetCore.WebUtilities;
using Newtonsoft.Json;
using RelayCore.Endpoints;
using RelayCore.Enums;
using RelayCore.Models;
using RelayShared.Services;
using SurrealDb.Net;
using SurrealDb.Net.Models;
namespace RelayCore.Services;
public class APIAuthService(SurrealDbClient _db)
{
public async Task<List<Users>> GetUsersAsync()
{
var users = await _db.Select<Users>("auth_users");
return users.Where(x => x.Username is not null).OrderByDescending(x=>x.CreatedAt).ToList();
}
public async Task<string?> UserSigninAsync(AuthSignin request, string ip, string userAgent)
{
var hasher = new PasswordHasher();
var users = await _db.Select<Users>("auth_users");
var user = users.FirstOrDefault(x => (x.Username.ToLower() == request.UserName.ToLower() ||
x.Email.ToLower() == request.UserName.ToLower()) &&
hasher.VerifyPassword(x.Id + request.Password, x.Password));
if (user == null)
return null;
var tokens = await _db.Select<Sessions>("auth_sessions");
var token = tokens.Where(x => x.UserId == user.Id && x.IpAddress == ip && x.UserAgent == userAgent && !x.Revoked)
.OrderByDescending(x => x.ExpiresAt).FirstOrDefault();
if (token != null)
if (token.ExpiresAt > DateTime.UtcNow)
return token.TokenHash;
//TODO: Generate TOKEN
var newToken = hasher.HashPassword($"{request.UserName}{userAgent}");
//TODO: Store TOKEN and Username for verification
var sessionId = await _db.Create("auth_sessions", new Sessions
{
UserId = user.Id,
TokenHash = newToken,
IssuedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(30),
DeviceName = "",
Revoked = false,
IpAddress = ip,
UserAgent = userAgent
});
//TODO: Add invalidation to TOKENs
return newToken;
}
public async Task<string?> UserRegisterAsync(AuthRegister request, string ip, string userAgent)
{
var hasher = new PasswordHasher();
var users = await _db.Select<Users>("auth_users");
var user = users.FirstOrDefault(x => x.Username.ToLower() == request.Username.ToLower() || x.Email.ToLower() == request.Email.ToLower());
if (user == null)
{
var now = DateTime.Now;
var created = await _db.Create("auth_users", new Users
{
Username = request.Username,
Email = request.Email,
CreatedAt = now,
UpdatedAt = now,
LastLogin = now,
TwoFactorEnabled = false,
EmailVerified = false,
AccountStatus = (int)AccountStatuses.Active,
OnlineStatus = (int)OnlineStatuses.Online,
});
var passwordHash = hasher.HashPassword(created.Id + request.Password);
await _db.Merge<PasswordHash, Users>(new PasswordHash
{
Id = created.Id,
Password = passwordHash
});
return await UserSigninAsync(new AuthSignin{UserName=request.Username, Password = request.Password}, ip, userAgent);
}
return null;
}
public async Task<bool> ServerVerifyUser(AuthUserVerify request)
{
var users = await _db.Select<Users>("auth_users");
var user = users.FirstOrDefault(x => x.Username == request.Username);
if (user == null)
return false;
var sessions = await _db.Select<Sessions>("auth_sessions");
var session = sessions.FirstOrDefault(x => x.TokenHash == request.Token && x.UserId == user.Id);
if (session == null)
return false;
return true;
}
public async Task<string?> ServerLicenseGenerate(AuthServerLicenseGenerate request)
{
var hasher = new PasswordHasher();
string token = null;
token = hasher.HashPassword(DateTime.Now.ToString("yyyyMMddHHmmss"));
var created = await _db.Create("auth_licenses", new DBLicense
{
Token = token,
IsClient = false,
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(365),
IsExpired = false,
});
return token;
}
public async Task<bool> ServerVerifyLicense(AuthServerLicenseVerify request)
{
var tokens = await _db.Select<DBLicense>("auth_licenses");
var token = tokens.FirstOrDefault(x => x.Token == request.License);
if (token != null)
return true;
return false;
}
}

View File

@@ -21,6 +21,8 @@ var bootstrapService = new ServerBootstrapService(db, coreClient, cryptoService)
await bootstrapService.InitializeAsync(); await bootstrapService.InitializeAsync();
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://127.0.0.1:5000/");
// builder.WebHost.UseUrls("http://192.168.1.92:5000/");
builder.Services.AddSingleton(db); builder.Services.AddSingleton(db);
builder.Services.AddScoped<RtcCallService>(); builder.Services.AddScoped<RtcCallService>();
@@ -30,7 +32,8 @@ var app = builder.Build();
app.MapGet("/", () => "Server Running!"); app.MapGet("/", () => "Server Running!");
app.MapRtcEndpoints(); app.MapRtcEndpoints();
var wssv = new WebSocketServer("ws://localhost:1337"); var wssv = new WebSocketServer("ws://127.0.0.1:5001");
// var wssv = new WebSocketServer("ws://192.168.1.92:5001");
wssv.AddWebSocketService<ChatSocketBehavior>("/"); wssv.AddWebSocketService<ChatSocketBehavior>("/");
RtcNotificationService.Server = wssv; RtcNotificationService.Server = wssv;

View File

@@ -1,4 +1,5 @@
using System.Text.Json; using System.Net.Http.Headers;
using System.Text.Json;
using RelayServer.Models; using RelayServer.Models;
using RelayServer.Services.Crypto; using RelayServer.Services.Crypto;
using RelayServer.Services.Data; using RelayServer.Services.Data;
@@ -11,9 +12,9 @@ using RelayShared.Services;
namespace RelayServer.Services.Chat; namespace RelayServer.Services.Chat;
/// <summary> /// <summary>
/// Handles websocket-based chat operations including client key registration, /// Handles websocket-based chat operations including authentication, client key
/// server key retrieval, channel listing, channel history loading, and encrypted /// registration, server key retrieval, channel listing, channel history loading,
/// channel message relay. /// and encrypted channel message relay.
/// </summary> /// </summary>
public class ChatSocketBehavior : WebSocketBehavior public class ChatSocketBehavior : WebSocketBehavior
{ {
@@ -25,82 +26,328 @@ public class ChatSocketBehavior : WebSocketBehavior
public static SurrealDb.Net.SurrealDbClient? Db { get; set; } public static SurrealDb.Net.SurrealDbClient? Db { get; set; }
/// <summary> /// <summary>
/// Routes incoming websocket messages to the appropriate chat handler. /// Routes incoming websocket messages to the appropriate handler via JSON dispatch.
/// Control messages carry an <c>Action</c> property; data messages carry a <c>Type</c> property.
/// </summary> /// </summary>
/// <param name="e">The websocket message event arguments.</param>
protected override void OnMessage(MessageEventArgs e) protected override void OnMessage(MessageEventArgs e)
{ {
var msg = e.Data; 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;
}
if (msg.StartsWith("RTC_JOIN_CHANNEL|"))
{
HandleRtcJoinChannel(msg);
return;
}
if (msg.StartsWith("RTC_LEAVE_CHANNEL|"))
{
HandleRtcLeaveChannel(msg);
return;
}
if (IsEncryptedRtcSignal(msg))
{
HandleEncryptedRtcSignal(msg);
return;
}
HandleEncryptedChatMessage(msg);
}
private static bool IsEncryptedRtcSignal(string msg)
{
try try
{ {
using var doc = JsonDocument.Parse(msg); using var doc = JsonDocument.Parse(msg);
var root = doc.RootElement; var root = doc.RootElement;
if (!root.TryGetProperty("Type", out var typeProp)) if (root.TryGetProperty("Action", out var actionProp))
return false; {
var action = (WsAction)actionProp.GetInt32();
var control = JsonSerializer.Deserialize<WsControlMessage>(msg)!;
DispatchControl(action, control);
return;
}
var type = (SignalType)typeProp.GetInt32(); if (root.TryGetProperty("Type", out var typeProp))
{
var type = (SignalType)typeProp.GetInt32();
return type == SignalType.EncryptedSignal; switch (type)
{
case SignalType.EncryptedSignal:
HandleEncryptedRtcSignal(msg);
return;
case SignalType.ClientEncryptedChat:
HandleEncryptedChatMessage(msg);
return;
}
}
Console.WriteLine($"Unrecognised WebSocket message from session={ID}: {msg[..Math.Min(200, msg.Length)]}");
} }
catch catch (Exception ex)
{ {
return false; Console.WriteLine($"WebSocket message error: session={ID}, error={ex.Message}");
} }
} }
/// <summary>
/// Dispatches a control message to the correct handler based on its action.
/// </summary>
private void DispatchControl(WsAction action, WsControlMessage control)
{
switch (action)
{
case WsAction.Authenticate:
HandleAuthenticate(control);
break;
case WsAction.RegisterKey:
HandleRegisterKey(control);
break;
case WsAction.GetServerKey:
HandleGetServerKey();
break;
case WsAction.GetChannels:
HandleGetChannels();
break;
case WsAction.GetHistory:
HandleGetHistory(control);
break;
case WsAction.RtcJoin:
HandleRtcJoinChannel(control);
break;
case WsAction.RtcLeave:
HandleRtcLeaveChannel(control);
break;
default:
Console.WriteLine($"Unknown WsAction {action} from session={ID}");
break;
}
}
// -------------------------------------------------------------------------
// Control handlers
// -------------------------------------------------------------------------
/// <summary>
/// Verifies a user token with the Core service. The HTTP call is wrapped in
/// a try-catch so that a network failure never crashes the WebSocket session.
/// </summary>
private async void HandleAuthenticate(WsControlMessage control)
{
if (string.IsNullOrWhiteSpace(control.Username) || string.IsNullOrWhiteSpace(control.Token))
{
Console.WriteLine("Invalid Authenticate payload.");
return;
}
try
{
using var core = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:1337") };
core.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
core.DefaultRequestHeaders.Add("User-Agent", "RelayServer");
var response = await core.PostAsJsonAsync("/server/verify/user", new AuthUserVerify
{
Username = control.Username,
Token = control.Token
});
Console.WriteLine($"Auth response for {control.Username}: {await response.Content.ReadAsStringAsync()}");
}
catch (Exception ex)
{
Console.WriteLine($"Auth verification failed for {control.Username}: {ex.Message}");
}
var result = new WsEventMessage { Event = WsEvent.Authenticated, Detail = control.Username };
Send(JsonSerializer.Serialize(result));
}
/// <summary>
/// Stores (or updates) the client's public key and registers the session in
/// <see cref="ConnectedClientService"/> so targeted delivery can resolve session ids.
/// </summary>
private void HandleRegisterKey(WsControlMessage control)
{
if (string.IsNullOrWhiteSpace(control.Username) || string.IsNullOrWhiteSpace(control.PublicKey))
{
Console.WriteLine("Invalid RegisterKey payload.");
return;
}
if (ClientKeyService is null)
{
Console.WriteLine("ClientKeyService is not initialized.");
return;
}
RegisterOrUpdateClientKeySync(control.Username, control.PublicKey);
ConnectedClientService.Register(ID, control.Username);
Console.WriteLine($"Registered key and session for {control.Username} (session={ID})");
var result = new WsEventMessage { Event = WsEvent.KeyRegistered, Detail = control.Username };
Send(JsonSerializer.Serialize(result));
}
/// <summary>
/// Sends the server's public key to the requesting client.
/// </summary>
private void HandleGetServerKey()
{
if (string.IsNullOrWhiteSpace(ServerPublicKey))
{
Console.WriteLine("Server public key is not initialized.");
return;
}
var payload = new ServerPublicKeyMessage
{
Type = SignalType.ServerPublicKey,
PublicKey = ServerPublicKey
};
Send(JsonSerializer.Serialize(payload));
}
/// <summary>
/// Sends the current list of channels to the connected client.
/// </summary>
private void HandleGetChannels()
{
if (Db is null)
{
Console.WriteLine("Db is not initialized.");
return;
}
var channels = GetChannelsSync()
.OrderBy(c => c.CreatedAt)
.Select(c => new ChannelItem
{
ChannelId = GetRecordId(c.Id),
Name = c.Name,
CreatedAt = c.CreatedAt
})
.ToList();
var payload = new SocketChannelList
{
Type = SignalType.ChannelList,
Channels = channels
};
Send(JsonSerializer.Serialize(payload));
}
/// <summary>
/// Loads stored channel history for a specific channel, decrypts it from
/// database storage format, and sends it back encrypted for the requesting client.
/// </summary>
private void HandleGetHistory(WsControlMessage control)
{
var username = control.Username;
var channelId = control.ChannelId;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(channelId))
{
Console.WriteLine("Invalid GetHistory payload.");
return;
}
if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey))
{
Console.WriteLine("History dependencies are not initialized.");
return;
}
var targetClient = GetClientPublicKeyByUsernameSync(username);
if (targetClient is null)
{
Console.WriteLine($"No public key found for history request user {username}");
return;
}
var allMessages = GetChannelMessagesSync();
var channelMessages = allMessages
.Where(m => m.ChannelId == channelId)
.OrderBy(m => m.CreatedAt)
.ToList();
Console.WriteLine($"Sending {channelMessages.Count} history messages to {username}");
foreach (var dbMessage in channelMessages)
{
string plainText;
try
{
plainText = ChannelCryptoService.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 = SignalType.EncryptedChat,
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 void HandleRtcJoinChannel(WsControlMessage control)
{
var username = control.Username;
var channelId = control.ChannelId;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(channelId))
{
Console.WriteLine("Invalid RtcJoin payload.");
return;
}
RtcChannelPresenceService.SetUser(ID, username);
RtcChannelPresenceService.JoinChannel(ID, channelId);
Console.WriteLine($"RTC presence joined: session={ID}, user={username}, channel={channelId}");
}
private void HandleRtcLeaveChannel(WsControlMessage control)
{
var username = control.Username;
var channelId = control.ChannelId;
if (string.IsNullOrWhiteSpace(channelId))
{
Console.WriteLine("Invalid RtcLeave payload.");
return;
}
if (RtcChannelPresenceService.IsInChannel(ID, channelId))
RtcChannelPresenceService.LeaveChannel(ID);
Console.WriteLine($"RTC presence left: session={ID}, user={username}, channel={channelId}");
}
// -------------------------------------------------------------------------
// Data message handlers
// -------------------------------------------------------------------------
/// <summary>
/// Decrypts an incoming encrypted RTC signal and re-encrypts it for every
/// other session in the same RTC channel.
/// </summary>
private void HandleEncryptedRtcSignal(string msg) private void HandleEncryptedRtcSignal(string msg)
{ {
Console.WriteLine("RTC SIGNAL HIT"); Console.WriteLine("RTC SIGNAL HIT");
SocketRtcSignalMessage? clientPayload; SocketRtcSignalMessage? clientPayload;
try try
@@ -178,121 +425,10 @@ public class ChatSocketBehavior : WebSocketBehavior
} }
/// <summary> /// <summary>
/// /// Decrypts an incoming encrypted chat message, stores it in the database,
/// then re-encrypts and delivers it individually to every connected server member.
/// Messages are never broadcast — each recipient receives their own encrypted copy.
/// </summary> /// </summary>
/// <param name="e"></param>
protected override void OnClose(CloseEventArgs e)
{
RtcChannelPresenceService.RemoveSession(ID);
Console.WriteLine($"WebSocket closed: session={ID}, code={e.Code}, reason={e.Reason}");
base.OnClose(e);
}
protected override void OnError(ErrorEventArgs e)
{
Console.WriteLine($"WebSocket error: session={ID}, message={e.Message}");
base.OnError(e);
}
/// <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))
return "Unknown";
var parts = senderUserId.Split(':', 2);
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);
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;
}
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)
{
Console.WriteLine("Db is not initialized.");
return;
}
//TODO: Update to include ChannelType and Group String on channels
var channels = GetChannelsSync()
.OrderBy(c => c.CreatedAt)
.Select(c => new ChannelItem()
{
ChannelId = GetRecordId(c.Id),
Name = c.Name,
CreatedAt = c.CreatedAt
})
.ToList();
var payload = new SocketChannelList
{
Type = SignalType.ChannelList,
Channels = channels
};
Send(JsonSerializer.Serialize(payload));
}
/// <summary>
/// Sends the server's public key to the connected websocket client.
/// </summary>
private void HandleGetServerKey()
{
if (string.IsNullOrWhiteSpace(ServerPublicKey))
{
Console.WriteLine("Server public key is not initialized.");
return;
}
var payload = new ServerPublicKeyMessage
{
Type = SignalType.ServerPublicKey,
PublicKey = ServerPublicKey
};
Send(JsonSerializer.Serialize(payload));
}
/// <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) private void HandleEncryptedChatMessage(string msg)
{ {
SocketEncryptedMessage? clientPayload; SocketEncryptedMessage? clientPayload;
@@ -335,9 +471,10 @@ public class ChatSocketBehavior : WebSocketBehavior
} }
Console.WriteLine($"Server decrypted message from {clientPayload.SenderUsername}: {plainText}"); Console.WriteLine($"Server decrypted message from {clientPayload.SenderUsername}: {plainText}");
try try
{ {
var dbEncrypted = ChannelCryptoService.Encrypt(plainText, ChannelDbKey); var dbEncrypted = ChannelCryptoService!.Encrypt(plainText, ChannelDbKey);
var savedMessage = CreateChannelMessageSync(new ChannelMessages var savedMessage = CreateChannelMessageSync(new ChannelMessages
{ {
@@ -357,19 +494,40 @@ public class ChatSocketBehavior : WebSocketBehavior
return; return;
} }
var allKeys = GetAllClientPublicKeysSync(); // Deliver to every connected server member individually.
var members = GetServerMembersSync();
foreach (var client in allKeys) foreach (var member in members)
{ {
var encrypted = E2EeHelper.EncryptForRecipient(plainText, client.PublicKey); // Derive the lowercase username from the stored record id (e.g. "users:keeper317").
var rawUsername = ExtractUsernameFromUserId(member.UserId);
Console.WriteLine($"Encrypting outbound message from {clientPayload.SenderUsername} for {client.Username}"); // Find all active sessions for this member (supports multi-device).
var sessionIds = ConnectedClientService.GetSessionsForUser(rawUsername);
if (sessionIds.Count == 0)
continue;
// Resolve the correctly-cased username as the client registered it.
var properUsername = sessionIds
.Select(ConnectedClientService.GetUsernameForSession)
.FirstOrDefault(u => u is not null) ?? rawUsername;
var clientKey = GetClientPublicKeyByUsernameSync(properUsername);
if (clientKey is null)
{
Console.WriteLine($"No public key for {properUsername}, skipping.");
continue;
}
var encrypted = E2EeHelper.EncryptForRecipient(plainText, clientKey.PublicKey);
Console.WriteLine($"Encrypting outbound message from {clientPayload.SenderUsername} for {properUsername}");
var outbound = new SocketEncryptedMessage var outbound = new SocketEncryptedMessage
{ {
Type = SignalType.EncryptedChat, Type = SignalType.EncryptedChat,
SenderUsername = clientPayload.SenderUsername, SenderUsername = clientPayload.SenderUsername,
RecipientUsername = client.Username, RecipientUsername = properUsername,
ChannelId = clientPayload.ChannelId, ChannelId = clientPayload.ChannelId,
CipherText = encrypted.CipherText, CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce, Nonce = encrypted.Nonce,
@@ -377,117 +535,35 @@ public class ChatSocketBehavior : WebSocketBehavior
EncryptedKey = encrypted.EncryptedKey EncryptedKey = encrypted.EncryptedKey
}; };
Sessions.Broadcast(JsonSerializer.Serialize(outbound)); var json = JsonSerializer.Serialize(outbound);
foreach (var sessionId in sessionIds)
Sessions.SendTo(json, sessionId);
} }
} }
/// <summary> // -------------------------------------------------------------------------
/// Loads stored channel history for a specific user and channel, decrypts it from // Lifecycle
/// database storage format, and sends it back encrypted for the requesting client. // -------------------------------------------------------------------------
/// </summary>
/// <param name="msg">The raw history request websocket message.</param> protected override void OnClose(CloseEventArgs e)
private void HandleGetHistory(string msg)
{ {
var parts = msg.Split('|', 3); ConnectedClientService.Unregister(ID);
RtcChannelPresenceService.RemoveSession(ID);
if (parts.Length < 3) Console.WriteLine($"WebSocket closed: session={ID}, code={e.Code}, reason={e.Reason}");
{ base.OnClose(e);
Console.WriteLine("Invalid GET_HISTORY payload.");
return;
}
var username = parts[1];
var channelId = parts[2];
if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey))
{
Console.WriteLine("History dependencies are not initialized.");
return;
}
var targetClient = GetClientPublicKeyByUsernameSync(username);
if (targetClient is null)
{
Console.WriteLine($"No public key found for history request user {username}");
return;
}
var allMessages = GetChannelMessagesSync();
var channelMessages = allMessages
.Where(m => m.ChannelId == channelId)
.OrderBy(m => m.CreatedAt)
.ToList();
Console.WriteLine($"Sending {channelMessages.Count} history messages to {username}");
foreach (var dbMessage in channelMessages)
{
string plainText;
try
{
plainText = ChannelCryptoService.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 = SignalType.EncryptedChat,
SenderUsername = ExtractUsernameFromUserId(dbMessage.SenderUserId),
RecipientUsername = username,
ChannelId = channelId,
CipherText = encrypted.CipherText,
Nonce = encrypted.Nonce,
Tag = encrypted.Tag,
EncryptedKey = encrypted.EncryptedKey
};
Send(JsonSerializer.Serialize(outbound));
}
} }
/// <summary> protected override void OnError(ErrorEventArgs e)
/// 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) Console.WriteLine($"WebSocket error: session={ID}, message={e.Message}");
return string.Empty; base.OnError(e);
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}";
} }
/// <summary> // -------------------------------------------------------------------------
/// Synchronously registers or updates a stored client public key using the async key service. // Sync DB helpers
/// </summary> // -------------------------------------------------------------------------
/// <param name="username">The client username.</param>
/// <param name="publicKey">The client's public key.</param>
private void RegisterOrUpdateClientKeySync(string username, string publicKey) private void RegisterOrUpdateClientKeySync(string username, string publicKey)
{ {
Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey)) Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey))
@@ -495,10 +571,6 @@ public class ChatSocketBehavior : WebSocketBehavior
.GetResult(); .GetResult();
} }
/// <summary>
/// Synchronously loads all channels from the database.
/// </summary>
/// <returns>A list of channel records.</returns>
private List<Channels> GetChannelsSync() private List<Channels> GetChannelsSync()
{ {
return Task.Run(async () => await Db!.Select<Channels>("channels")) return Task.Run(async () => await Db!.Select<Channels>("channels"))
@@ -507,13 +579,6 @@ public class ChatSocketBehavior : WebSocketBehavior
.ToList(); .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) private ClientPublicKeys? GetClientPublicKeyByUsernameSync(string username)
{ {
return Task.Run(async () => await ClientKeyService!.GetByUsernameAsync(username)) return Task.Run(async () => await ClientKeyService!.GetByUsernameAsync(username))
@@ -521,21 +586,6 @@ public class ChatSocketBehavior : WebSocketBehavior
.GetResult(); .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() private List<ChannelMessages> GetChannelMessagesSync()
{ {
return Task.Run(async () => await Db!.Select<ChannelMessages>("channel_messages")) return Task.Run(async () => await Db!.Select<ChannelMessages>("channel_messages"))
@@ -544,11 +594,6 @@ public class ChatSocketBehavior : WebSocketBehavior
.ToList(); .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) private ChannelMessages CreateChannelMessageSync(ChannelMessages message)
{ {
return Task.Run(async () => await Db!.Create("channel_messages", message)) return Task.Run(async () => await Db!.Create("channel_messages", message))
@@ -556,10 +601,49 @@ public class ChatSocketBehavior : WebSocketBehavior
.GetResult(); .GetResult();
} }
private List<ServerMembers> GetServerMembersSync()
{
return Task.Run(async () => await Db!.Select<ServerMembers>("server_members"))
.GetAwaiter()
.GetResult()
.ToList();
}
// -------------------------------------------------------------------------
// Utilities
// -------------------------------------------------------------------------
/// <summary> /// <summary>
/// /// Extracts a display username from a stored user record id value
/// (e.g. "users:keeper317" → "keeper317").
/// </summary> /// </summary>
/// <returns></returns> private static string ExtractUsernameFromUserId(string senderUserId)
{
if (string.IsNullOrWhiteSpace(senderUserId))
return "Unknown";
var parts = senderUserId.Split(':', 2);
return parts.Length == 2 ? parts[1] : senderUserId;
}
/// <summary>
/// Converts a SurrealDB record id object into a "table:id" string.
/// </summary>
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 bool EnsureCoreReady() private bool EnsureCoreReady()
{ {
if (ClientKeyService is null || Db is null) if (ClientKeyService is null || Db is null)
@@ -571,10 +655,6 @@ public class ChatSocketBehavior : WebSocketBehavior
return true; return true;
} }
/// <summary>
///
/// </summary>
/// <returns></returns>
private bool EnsureCryptoReady() private bool EnsureCryptoReady()
{ {
if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey)) if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey))
@@ -591,50 +671,4 @@ public class ChatSocketBehavior : WebSocketBehavior
return true; return true;
} }
/// <summary>
///
/// </summary>
/// <param name="msg"></param>
private void HandleRtcJoinChannel(string msg)
{
var parts = msg.Split('|', 3);
if (parts.Length < 3)
{
Console.WriteLine("Invalid RTC_JOIN_CHANNEL payload.");
return;
}
var username = parts[1];
var channelId = parts[2];
RtcChannelPresenceService.SetUser(ID, username);
RtcChannelPresenceService.JoinChannel(ID, channelId);
Console.WriteLine($"RTC presence joined: session={ID}, user={username}, channel={channelId}");
}
/// <summary>
///
/// </summary>
/// <param name="msg"></param>
private void HandleRtcLeaveChannel(string msg)
{
var parts = msg.Split('|', 3);
if (parts.Length < 3)
{
Console.WriteLine("Invalid RTC_LEAVE_CHANNEL payload.");
return;
}
var username = parts[1];
var channelId = parts[2];
if (RtcChannelPresenceService.IsInChannel(ID, channelId))
{
RtcChannelPresenceService.LeaveChannel(ID);
}
Console.WriteLine($"RTC presence left: session={ID}, user={username}, channel={channelId}");
}
} }

View File

@@ -0,0 +1,56 @@
using System.Collections.Concurrent;
namespace RelayServer.Services.Chat;
public static class ConnectedClientService
{
private static readonly ConcurrentDictionary<string, string> SessionToUsername = new();
private static readonly ConcurrentDictionary<string, HashSet<string>> UsernameToSessions =
new(StringComparer.OrdinalIgnoreCase);
public static void Register(string sessionId, string username)
{
if (SessionToUsername.TryGetValue(sessionId, out var oldUsername) &&
!string.Equals(oldUsername, username, StringComparison.OrdinalIgnoreCase))
{
RemoveSessionFromUsername(sessionId, oldUsername);
}
SessionToUsername[sessionId] = username;
var sessions = UsernameToSessions.GetOrAdd(username, _ => new HashSet<string>(StringComparer.Ordinal));
lock (sessions)
sessions.Add(sessionId);
}
public static void Unregister(string sessionId)
{
if (SessionToUsername.TryRemove(sessionId, out var username))
RemoveSessionFromUsername(sessionId, username);
}
public static IReadOnlyCollection<string> GetSessionsForUser(string username)
{
if (UsernameToSessions.TryGetValue(username, out var sessions))
lock (sessions)
return sessions.ToList();
return Array.Empty<string>();
}
public static string? GetUsernameForSession(string sessionId) =>
SessionToUsername.TryGetValue(sessionId, out var u) ? u : null;
private static void RemoveSessionFromUsername(string sessionId, string username)
{
if (!UsernameToSessions.TryGetValue(username, out var sessions))
return;
lock (sessions)
{
sessions.Remove(sessionId);
if (sessions.Count == 0)
UsernameToSessions.TryRemove(username, out _);
}
}
}

View File

@@ -0,0 +1,40 @@
namespace RelayShared.Services;
public class AuthSignin
{
public string UserName { get; set; }
public string Password { get; set; }
}
public class AuthRegister
{
public string Username { get; set; }
public string Password { get; set; }
public string Email { get; set; }
}
public class AuthUserVerify
{
public string Username { get; set; }
public string Token { get; set; }
}
public class AuthServerLicenseVerify
{
public string License { get; set; }
}
public class AuthServerLicenseGenerate
{
public string Server { get; set; }
public string Length {get; set;} //TODO: Convert to Enum
}
public class DBLicense
{
public string Token {get; set;}
public bool IsClient {get; set;}
public DateTime CreatedAt {get; set;}
public DateTime ExpiresAt {get; set;}
public bool IsExpired {get; set;}
}

View File

@@ -0,0 +1,34 @@
namespace RelayShared.Services;
public enum WsAction
{
Authenticate,
RegisterKey,
GetServerKey,
GetChannels,
GetHistory,
RtcJoin,
RtcLeave
}
public enum WsEvent
{
Authenticated,
KeyRegistered,
Error
}
public sealed class WsControlMessage
{
public WsAction Action { get; set; }
public string? Username { get; set; }
public string? Token { get; set; }
public string? ChannelId { get; set; }
public string? PublicKey { get; set; }
}
public sealed class WsEventMessage
{
public WsEvent Event { get; set; }
public string? Detail { get; set; }
}

View File

@@ -66,7 +66,7 @@ Start-Sleep -Seconds 5
$testScript = New-TabScript -Name "Test" -Content @" $testScript = New-TabScript -Name "Test" -Content @"
Set-Location '$root' Set-Location '$root'
Start-Sleep -Seconds 25 Start-Sleep -Seconds 5
& '$clientExe' --user Test & '$clientExe' --user Test
"@ "@

63
start-servers.ps1 Normal file
View File

@@ -0,0 +1,63 @@
$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"
$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'
"@
$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`""
) -join " ; "
Write-Host ""
Write-Host "Everything started."
Write-Host "Close out terminal to end all applications."
Start-Process wt.exe -ArgumentList $wtArgs