Summary Update.
This commit is contained in:
@@ -12,19 +12,68 @@ using RelayShared.Services;
|
||||
namespace RelayServer.Services.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Handles all WebSocket traffic: authentication, key registration, channel management,
|
||||
/// encrypted chat relay, message editing/deletion, typing indicators, and edit history.
|
||||
/// The server-side WebSocket endpoint. Every client connection creates one instance of this
|
||||
/// class. WebSocketSharp owns the lifecycle: it constructs the behavior, calls OnMessage for
|
||||
/// each incoming frame, and calls OnClose when the connection drops.
|
||||
///
|
||||
/// MESSAGE FLOW (data plane — chat message):
|
||||
/// 1. Client sends a SocketEncryptedMessage with SignalType.ClientEncryptedChat.
|
||||
/// Payload is JSON-serialised ChatMessageContent, encrypted with the server's public key.
|
||||
/// 2. OnMessage parses the JSON, identifies Type, routes to HandleEncryptedChatMessage.
|
||||
/// 3. Permission check via PermissionService.CanSendMessagesAsync.
|
||||
/// 4. Decrypt with ServerPrivateKey → get plaintext JSON.
|
||||
/// 5. Re-encrypt with ChannelDbKey (AES-GCM only, no RSA) → store in channel_messages table.
|
||||
/// 6. For each connected server member: re-encrypt with their client public key, deliver
|
||||
/// via Sessions.SendTo to every one of their active sessions (multi-device).
|
||||
/// 7. If the origin channel has LinkedFileChannelId set, MirrorAttachmentIfNeeded also
|
||||
/// stores+delivers a trimmed copy into the linked File channel.
|
||||
///
|
||||
/// MESSAGE FLOW (control plane — e.g. CreateChannel):
|
||||
/// 1. Client sends a WsControlMessage with Action=CreateChannel.
|
||||
/// 2. OnMessage sees the "Action" JSON property, routes via DispatchControl.
|
||||
/// 3. Permission check, DB write, then BroadcastChannelList rebuilds the channel list per
|
||||
/// user (because CanPost/CanManage are computed per-user) and pushes it to everyone.
|
||||
///
|
||||
/// STATE STORES used here:
|
||||
/// - ConnectedClientService: session ↔ username mapping (in-memory, multi-device aware).
|
||||
/// Populated by HandleRegisterKey, cleared by OnClose.
|
||||
/// - RtcChannelPresenceService: session ↔ voice channel mapping. Populated by RtcJoin,
|
||||
/// cleared by RtcLeave / OnClose.
|
||||
/// - SurrealDB tables: channel_messages, channels, server_members, roles, user_roles,
|
||||
/// channel_permissions, client_public_keys, server_encryption_keys, channel_message_edits.
|
||||
///
|
||||
/// CRITICAL invariant: this class is constructed by WebSocketSharp and has no constructor
|
||||
/// hook for DI, so ALL services are static (set once by Program.cs at boot).
|
||||
/// </summary>
|
||||
public class ChatSocketBehavior : WebSocketBehavior
|
||||
{
|
||||
/// <summary>Reads/writes the client_public_keys table. Wired by Program.cs at boot.</summary>
|
||||
public static ClientKeyService? ClientKeyService { get; set; }
|
||||
|
||||
/// <summary>The permission ladder evaluator. Wired by Program.cs at boot.</summary>
|
||||
public static PermissionService? PermissionService { get; set; }
|
||||
|
||||
/// <summary>Base64 RSA public key — clients use this to encrypt outbound payloads to the server.</summary>
|
||||
public static string? ServerPublicKey { get; set; }
|
||||
|
||||
/// <summary>Base64 RSA private key — used to decrypt inbound payloads. Never leaves the server.</summary>
|
||||
public static string? ServerPrivateKey { get; set; }
|
||||
|
||||
/// <summary>Base64 AES-256 key for at-rest encryption of channel_messages.CipherText rows.</summary>
|
||||
public static string? ChannelDbKey { get; set; }
|
||||
|
||||
/// <summary>AES-GCM-only encryption for stored messages. Wired by Program.cs at boot.</summary>
|
||||
public static ChannelCryptoService? ChannelCryptoService { get; set; }
|
||||
|
||||
/// <summary>The SurrealDB connection. Wired by Program.cs at boot.</summary>
|
||||
public static SurrealDb.Net.SurrealDbClient? Db { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// WebSocketSharp callback fired for every incoming text frame. Peeks the JSON to identify
|
||||
/// "Action" (control-plane) vs "Type" (data-plane), then routes to the right handler.
|
||||
/// All exceptions are caught and logged — they MUST NOT propagate or WebSocketSharp will
|
||||
/// drop the connection.
|
||||
/// </summary>
|
||||
protected override void OnMessage(MessageEventArgs e)
|
||||
{
|
||||
var msg = e.Data;
|
||||
@@ -62,6 +111,7 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Switches on WsAction to the matching Handle* method. Pure routing — no I/O.</summary>
|
||||
private void DispatchControl(WsAction action, WsControlMessage c)
|
||||
{
|
||||
switch (action)
|
||||
@@ -81,6 +131,14 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a Core-issued user token against the Core service. The HTTP call is wrapped in
|
||||
/// try/catch so that a Core outage doesn't drop the chat session — we still ack with
|
||||
/// WsEvent.Authenticated so the rest of the boot handshake can proceed.
|
||||
///
|
||||
/// NOTE async void here is unavoidable (it's an event handler) but every exception path
|
||||
/// must be caught locally or WebSocketSharp will tear down the session.
|
||||
/// </summary>
|
||||
private async void HandleAuthenticate(WsControlMessage c)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.Token))
|
||||
@@ -108,6 +166,11 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Authenticated, Detail = c.Username }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores or updates the client's RSA public key in client_public_keys, then registers the
|
||||
/// (sessionId, username) mapping in ConnectedClientService. After this fires the server can
|
||||
/// route encrypted chat messages to this user's connected devices.
|
||||
/// </summary>
|
||||
private void HandleRegisterKey(WsControlMessage c)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.PublicKey))
|
||||
@@ -125,12 +188,17 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.KeyRegistered, Detail = c.Username }));
|
||||
}
|
||||
|
||||
/// <summary>Sends the server's public RSA key. Called once per session right after RegisterKey.</summary>
|
||||
private void HandleGetServerKey()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ServerPublicKey)) { Console.WriteLine("Server public key not initialised."); return; }
|
||||
Send(JsonSerializer.Serialize(new ServerPublicKeyMessage { Type = SignalType.ServerPublicKey, PublicKey = ServerPublicKey }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a channel list with CanPost/CanManage/visibility resolved for this specific user.
|
||||
/// The username is looked up by session ID so the client never has to spoof it.
|
||||
/// </summary>
|
||||
private void HandleGetChannels()
|
||||
{
|
||||
if (Db is null) { Console.WriteLine("Db null."); return; }
|
||||
@@ -142,6 +210,14 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
Send(JsonSerializer.Serialize(new SocketChannelList { Type = SignalType.ChannelList, Channels = channels }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams the channel's full message history back to the requester. Each message is:
|
||||
/// 1. Decrypted from the channel DB key (ChannelCryptoService.Decrypt).
|
||||
/// 2. Re-encrypted with the requester's public key (E2EeHelper.EncryptForRecipient).
|
||||
/// 3. Sent as an individual SocketEncryptedMessage frame.
|
||||
/// Deleted messages are sent as tombstones (IsDeleted=true, no ciphertext) so the client
|
||||
/// can render a placeholder without trying to decrypt.
|
||||
/// </summary>
|
||||
private void HandleGetHistory(WsControlMessage c)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.ChannelId))
|
||||
@@ -194,6 +270,10 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks the session as present in a voice channel. Gated by CanSpeakAsync — if the user's
|
||||
/// role is denied Speak here we reject with WsEvent.Error and refuse to register presence.
|
||||
/// </summary>
|
||||
private void HandleRtcJoinChannel(WsControlMessage c)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.ChannelId))
|
||||
@@ -215,6 +295,7 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
Console.WriteLine($"RTC join: session={ID}, user={c.Username}, channel={c.ChannelId}");
|
||||
}
|
||||
|
||||
/// <summary>Clears the session's voice-channel presence. Idempotent — safe to call when not in a channel.</summary>
|
||||
private void HandleRtcLeaveChannel(WsControlMessage c)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(c.ChannelId) && RtcChannelPresenceService.IsInChannel(ID, c.ChannelId))
|
||||
@@ -222,6 +303,11 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
Console.WriteLine($"RTC leave: session={ID}, user={c.Username}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcasts "{Username} is typing…" to every connected server member EXCEPT the sender.
|
||||
/// Sender's username comes from ConnectedClientService (not the message payload) so a
|
||||
/// malicious client can't impersonate someone else's typing.
|
||||
/// </summary>
|
||||
private void HandleTyping(WsControlMessage c)
|
||||
{
|
||||
var senderUsername = ConnectedClientService.GetUsernameForSession(ID);
|
||||
@@ -244,6 +330,11 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams every prior version of a message back to the requester. Each entry is decrypted
|
||||
/// from the channel key then re-encrypted for the requester's public key. Drives the
|
||||
/// "(edited)" tap-popup on the client.
|
||||
/// </summary>
|
||||
private void HandleGetEditHistory(WsControlMessage c)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(c.MessageId) || string.IsNullOrWhiteSpace(c.Username)) return;
|
||||
@@ -283,6 +374,10 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Permission-gated channel creation. On success, broadcasts the new channel list to every
|
||||
/// connected member (computed per-user since CanPost/CanManage depend on the recipient).
|
||||
/// </summary>
|
||||
private void HandleCreateChannel(WsControlMessage c)
|
||||
{
|
||||
var username = ConnectedClientService.GetUsernameForSession(ID);
|
||||
@@ -315,6 +410,10 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
BroadcastChannelList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Permission-gated soft-delete (sets IsDeleted on the row, doesn't actually remove it).
|
||||
/// Broadcasts a fresh channel list after — clients drop the channel from their sidebar.
|
||||
/// </summary>
|
||||
private void HandleDeleteChannel(WsControlMessage c)
|
||||
{
|
||||
var username = ConnectedClientService.GetUsernameForSession(ID);
|
||||
@@ -340,6 +439,11 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
BroadcastChannelList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Relays an encrypted WebRTC SDP/ICE signal to every other session in the same voice
|
||||
/// channel. Decrypts with the server's private key, re-encrypts per-recipient. The server
|
||||
/// never stores RTC signals — pure forwarding.
|
||||
/// </summary>
|
||||
private void HandleEncryptedRtcSignal(string msg)
|
||||
{
|
||||
SocketRtcSignalMessage? payload;
|
||||
@@ -374,6 +478,10 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The main chat-message path. Permission gate → server-side decrypt → store with channel
|
||||
/// key → DeliverToServerMembers (per-user re-encrypt + send) → MirrorAttachmentIfNeeded.
|
||||
/// </summary>
|
||||
private void HandleEncryptedChatMessage(string msg)
|
||||
{
|
||||
SocketEncryptedMessage? payload;
|
||||
@@ -429,6 +537,11 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
MirrorAttachmentIfNeeded(plainText, payload.SenderUsername, payload.ChannelId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If the origin channel has LinkedFileChannelId set and the message has a non-gif
|
||||
/// attachment, stores+delivers a trimmed copy ("📎 Shared from #X by Y" + attachment)
|
||||
/// into the linked File channel. No-op for plain text messages.
|
||||
/// </summary>
|
||||
private void MirrorAttachmentIfNeeded(string plainText, string senderUsername, string originChannelId)
|
||||
{
|
||||
ChatMessageContent? content;
|
||||
@@ -483,6 +596,11 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
Console.WriteLine($"Mirrored attachment from {originChannelId} to file channel {fileChannelId}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ownership-gated edit. Saves the OLD ciphertext as a ChannelMessageEdits row before
|
||||
/// overwriting the current row, so the edit chain is preserved. Broadcasts MessageEdited
|
||||
/// with the new ciphertext so every recipient updates their bubble in place.
|
||||
/// </summary>
|
||||
private void HandleEditMessage(string msg)
|
||||
{
|
||||
SocketEncryptedMessage? request;
|
||||
@@ -542,6 +660,11 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
request.MessageId, SignalType.MessageEdited, isEdited: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-delete (sets IsDeleted on the row). Allowed for the message author OR anyone with
|
||||
/// ManageMessages permission in the channel. Broadcasts a tombstone event to every
|
||||
/// connected member; their client swaps the bubble to a "deleted" placeholder.
|
||||
/// </summary>
|
||||
private void HandleDeleteMessage(string msg)
|
||||
{
|
||||
SocketEncryptedMessage? request;
|
||||
@@ -587,6 +710,14 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The fan-out for any chat-message delivery (new send, edit broadcast). For each
|
||||
/// server_members row, looks up active sessions, fetches that user's public key, encrypts
|
||||
/// the plaintext for them, and sends to every one of their sessions (multi-device).
|
||||
///
|
||||
/// "ProperUsername" is the mixed-case version captured at RegisterKey time, used so the
|
||||
/// client's case-insensitive compare picks up the message instead of dropping it silently.
|
||||
/// </summary>
|
||||
private void DeliverToServerMembers(
|
||||
string plainText, string senderUsername, string channelId,
|
||||
string messageId, SignalType signalType, bool isEdited)
|
||||
@@ -619,6 +750,10 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a freshly-built channel list to every connected member. Has to compute the list
|
||||
/// PER user because CanPost/CanManage/visibility are user-specific. Called after Create/Delete.
|
||||
/// </summary>
|
||||
private void BroadcastChannelList()
|
||||
{
|
||||
foreach (var member in GetServerMembersSync())
|
||||
@@ -639,6 +774,10 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the channel list a specific user can see, with CanPost/CanManage flags filled
|
||||
/// in. Visibility (ViewChannel) determines inclusion — denied channels are filtered out.
|
||||
/// </summary>
|
||||
private List<ChannelItem> BuildChannelListForUser(string username)
|
||||
{
|
||||
var rawChannels = GetChannelsSync()
|
||||
@@ -679,6 +818,10 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WebSocketSharp callback when the connection drops (clean close OR network drop). Clears
|
||||
/// both presence registries so other clients aren't trying to send to a dead session.
|
||||
/// </summary>
|
||||
protected override void OnClose(CloseEventArgs e)
|
||||
{
|
||||
ConnectedClientService.Unregister(ID);
|
||||
@@ -687,12 +830,19 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
base.OnClose(e);
|
||||
}
|
||||
|
||||
/// <summary>WebSocketSharp callback for socket-level errors. Logged but non-fatal.</summary>
|
||||
protected override void OnError(ErrorEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"WS error: session={ID}, message={e.Message}");
|
||||
base.OnError(e);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sync DB shims. WebSocketSharp's handler methods are synchronous, so async DB calls
|
||||
// are wrapped in Task.Run(...).GetAwaiter().GetResult(). Not ideal but pragmatic — the
|
||||
// alternative is refactoring WebSocketSharp's behavior model.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void RegisterOrUpdateClientKeySync(string username, string publicKey) =>
|
||||
Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey)).GetAwaiter().GetResult();
|
||||
|
||||
@@ -727,6 +877,7 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
private List<ServerMembers> GetServerMembersSync() =>
|
||||
Task.Run(async () => await Db!.Select<ServerMembers>("server_members")).GetAwaiter().GetResult().ToList();
|
||||
|
||||
/// <summary>"users:keeper317" → "keeper317". Stored as Surreal record id, displayed as plain name.</summary>
|
||||
private static string ExtractUsernameFromUserId(string senderUserId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(senderUserId)) return "Unknown";
|
||||
@@ -734,6 +885,7 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
return parts.Length == 2 ? parts[1] : senderUserId;
|
||||
}
|
||||
|
||||
/// <summary>SurrealDB's Id object → "table:recordId" string. Used for storing parent refs as strings in child rows.</summary>
|
||||
private static string GetRecordId(object? id)
|
||||
{
|
||||
if (id is null) return string.Empty;
|
||||
@@ -743,12 +895,14 @@ public class ChatSocketBehavior : WebSocketBehavior
|
||||
return $"{root.GetProperty("Table").GetString()}:{root.GetProperty("Id").GetString()}";
|
||||
}
|
||||
|
||||
/// <summary>Guard: returns true if the DB and key service are both initialised. Logs and returns false otherwise.</summary>
|
||||
private bool EnsureCoreReady()
|
||||
{
|
||||
if (ClientKeyService is null || Db is null) { Console.WriteLine("Core services null."); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Guard: returns true if encryption keys + channel crypto service are all set. Logs and returns false otherwise.</summary>
|
||||
private bool EnsureCryptoReady()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey) || ChannelCryptoService is null)
|
||||
|
||||
Reference in New Issue
Block a user