improve voice chat security by implementing join validation
This commit is contained in:
parent
2ca092b017
commit
1915198d82
7 changed files with 392 additions and 9 deletions
|
|
@ -3,6 +3,14 @@ using System.Text.Json.Serialization;
|
|||
namespace LarpixVoice;
|
||||
|
||||
[JsonSerializable(typeof(string[]))]
|
||||
[JsonSerializable(typeof(VoiceAuthBody))]
|
||||
internal partial class JsonAppSerializerContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public record VoiceAuthBody(
|
||||
[property: JsonPropertyName("room")] string Room,
|
||||
[property: JsonPropertyName("userId")] string UserId,
|
||||
[property: JsonPropertyName("publicId")] string PublicId,
|
||||
[property: JsonPropertyName("iv")] string Iv,
|
||||
[property: JsonPropertyName("ct")] string Ct);
|
||||
|
|
@ -6,6 +6,14 @@ public class Program
|
|||
{
|
||||
public static ConcurrentDictionary<string, ConcurrentDictionary<ulong, Session>> Rooms = new ();
|
||||
|
||||
// Per-IP rate limit for the unauthenticated /room/users endpoint: legit
|
||||
// clients poll roughly once per second while in a call, so the default of
|
||||
// 120/min per IP (ROOM_USERS_RATE_LIMIT) leaves ample headroom (incl.
|
||||
// several users behind one NAT) while making bulk room-enumeration scans
|
||||
// impractical. Fixed 60s window: (request count, window reset unix time).
|
||||
// Entries are pruned in the 1s loop below.
|
||||
public static ConcurrentDictionary<string, (int count, long resetAt)> UsersListLimits = new ();
|
||||
|
||||
public static ulong TotalBytesSent = 0;
|
||||
public static ulong TotalBytesReceived = 0;
|
||||
private static ulong CurrentBandwidthKps = 0;
|
||||
|
|
@ -25,6 +33,12 @@ public class Program
|
|||
Interlocked.Exchange(ref TotalBytesReceived, 0);
|
||||
Interlocked.Exchange(ref TotalBytesSent, 0);
|
||||
CurrentBandwidthKps = (sentDelta + receivedDelta);
|
||||
|
||||
long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
foreach (var entry in UsersListLimits)
|
||||
{
|
||||
if (entry.Value.resetAt <= nowSec) UsersListLimits.TryRemove(entry.Key, out _);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -67,6 +81,16 @@ public class Program
|
|||
await context.Response.WriteAsync(Interlocked.Read(ref CurrentBandwidthKps).ToString());
|
||||
return;
|
||||
case "/room/users":
|
||||
string callerIp = context.Connection.RemoteIpAddress?.ToString() ?? "";
|
||||
long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var window = UsersListLimits.AddOrUpdate(callerIp,
|
||||
(1, nowSec + 60),
|
||||
(_, old) => old.resetAt <= nowSec ? (1, nowSec + 60) : (old.count + 1, old.resetAt));
|
||||
if (window.count > Utils.ROOM_USERS_RATE_LIMIT)
|
||||
{
|
||||
context.Response.StatusCode = 429;
|
||||
return;
|
||||
}
|
||||
string roomId = context.Request.Query["room"].ToString();
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ public class Requests
|
|||
|
||||
string roomId = context.Request.Query["room"].ToString();
|
||||
string userIdStr = context.Request.Query["userId"].ToString();
|
||||
string secret = context.Request.Query["secret"].ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(roomId) || !ulong.TryParse(userIdStr, out ulong clientId))
|
||||
{
|
||||
|
|
@ -27,6 +26,56 @@ public class Requests
|
|||
}
|
||||
|
||||
using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
|
||||
// --- Ticket handshake: first frame must be "<publicId>.<iv>.<ct>".
|
||||
// Unauthenticated connections never touch the room roster; the blob is
|
||||
// validated by the main server, which consumes it atomically.
|
||||
// Tiny buffer: unauthenticated clients cannot pin large allocations. ---
|
||||
var authBuffer = ArrayPool<byte>.Shared.Rent(2048);
|
||||
string ticket;
|
||||
using (var authCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
|
||||
{
|
||||
WebSocketReceiveResult firstFrame;
|
||||
try
|
||||
{
|
||||
firstFrame = await webSocket.ReceiveAsync(new ArraySegment<byte>(authBuffer), authCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Console.WriteLine($"Voice ws: ticket timeout, room={roomId}");
|
||||
await SafeClose(webSocket, "Ticket timeout");
|
||||
return;
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstFrame.MessageType != WebSocketMessageType.Text || !firstFrame.EndOfMessage || firstFrame.Count == 0)
|
||||
{
|
||||
await SafeClose(webSocket, "Invalid ticket");
|
||||
return;
|
||||
}
|
||||
ticket = System.Text.Encoding.UTF8.GetString(authBuffer, 0, firstFrame.Count);
|
||||
}
|
||||
ArrayPool<byte>.Shared.Return(authBuffer);
|
||||
|
||||
string[] ticketParts = ticket.Split('.');
|
||||
bool frameShapeOk = ticketParts.Length == 3
|
||||
&& ticketParts[0].Length > 0 && ticketParts[0].Length <= 64
|
||||
&& ticketParts[1].Length == 16
|
||||
&& ticketParts[2].Length > 0 && ticketParts[2].Length <= 128;
|
||||
|
||||
Console.WriteLine($"Voice ws: validating {ticketParts[0][..Math.Min(8, ticketParts[0].Length)]} room={roomId}");
|
||||
if (!frameShapeOk ||
|
||||
!await Utils.AuthorizeConnection(ticketParts[0], ticketParts[1], ticketParts[2], roomId, clientId))
|
||||
{
|
||||
Console.WriteLine($"Voice ws: invalid ticket, room={roomId}");
|
||||
await SafeClose(webSocket, "Invalid ticket");
|
||||
return;
|
||||
}
|
||||
|
||||
Session currentSession = null;
|
||||
var roomClients = Rooms.GetOrAdd(roomId, _ => new ConcurrentDictionary<ulong, Session>());
|
||||
|
||||
if (roomClients.TryGetValue(clientId, out var oldSession) && oldSession.Socket.State == WebSocketState.Open)
|
||||
|
|
@ -40,7 +89,7 @@ public class Requests
|
|||
}
|
||||
}
|
||||
|
||||
var currentSession = new Session(webSocket);
|
||||
currentSession = new Session(webSocket);
|
||||
roomClients[clientId] = currentSession;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
|
|
@ -53,7 +102,7 @@ public class Requests
|
|||
{
|
||||
await currentSession.Socket.SendAsync(new ArraySegment<byte>(packet.Buffer, 0, packet.Length),
|
||||
WebSocketMessageType.Binary, true, CancellationToken.None);
|
||||
|
||||
|
||||
Interlocked.Add(ref TotalBytesSent, (ulong)packet.Length);
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +116,7 @@ public class Requests
|
|||
}
|
||||
});
|
||||
|
||||
// Big buffer only for authenticated media traffic.
|
||||
var receiveBuffer = ArrayPool<byte>.Shared.Rent(1024 * 1024);
|
||||
try
|
||||
{
|
||||
|
|
@ -80,10 +130,10 @@ public class Requests
|
|||
result = await webSocket.ReceiveAsync(
|
||||
new ArraySegment<byte>(receiveBuffer, totalBytes, receiveBuffer.Length - totalBytes),
|
||||
CancellationToken.None);
|
||||
|
||||
|
||||
totalBytes += result.Count;
|
||||
Interlocked.Add(ref TotalBytesReceived, (ulong)result.Count);
|
||||
|
||||
|
||||
} while (!result.EndOfMessage);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close) break;
|
||||
|
|
@ -121,4 +171,16 @@ public class Requests
|
|||
if (roomClients.IsEmpty) Rooms.TryRemove(roomId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SafeClose(WebSocket socket, string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (socket.State == WebSocketState.Open)
|
||||
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, reason, CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,19 @@ public class Utils
|
|||
public static int PORT = 8091;
|
||||
public static int MAIN_PORT = 8090;
|
||||
public static string SERVER_KEY = "SecretThingOMGSoHardToGuess";
|
||||
|
||||
public static string MAIN_URL = "";
|
||||
|
||||
// Rate limits, requests per minute (fixed 60s window).
|
||||
public static int ROOM_USERS_RATE_LIMIT = 120;
|
||||
|
||||
// Shared with the main server: authenticates internal validation requests.
|
||||
public static string VOICE_KEY = "";
|
||||
|
||||
private static readonly System.Net.Http.HttpClient AuthHttp = new()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(3)
|
||||
};
|
||||
|
||||
public static void LoadEnv()
|
||||
{
|
||||
if (File.Exists(".env"))
|
||||
|
|
@ -44,5 +56,60 @@ public class Utils
|
|||
MAIN_PORT = int.Parse(port);
|
||||
}
|
||||
SERVER_KEY = Environment.GetEnvironmentVariable("SERVER_KEY") ?? SERVER_KEY;
|
||||
VOICE_KEY = Environment.GetEnvironmentVariable("VOICE_KEY") ?? VOICE_KEY;
|
||||
MAIN_URL = Environment.GetEnvironmentVariable("MAIN_URL") ?? $"http://127.0.0.1:{MAIN_PORT}";
|
||||
|
||||
string? roomUsersRateLimitStr = Environment.GetEnvironmentVariable("ROOM_USERS_RATE_LIMIT");
|
||||
if (roomUsersRateLimitStr != null && int.TryParse(roomUsersRateLimitStr, out int parsedRoomUsersLimit))
|
||||
{
|
||||
ROOM_USERS_RATE_LIMIT = parsedRoomUsersLimit;
|
||||
}
|
||||
}
|
||||
|
||||
// Asks the main server to validate (and atomically consume) the ticket
|
||||
// blob presented on a connection's first frame. Fail-closed: any network
|
||||
// problem, bad HMAC, timeout or "deny" response rejects the connection.
|
||||
public static async Task<bool> AuthorizeConnection(string publicId, string iv, string ct, string roomId, ulong userId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(VOICE_KEY) || string.IsNullOrEmpty(MAIN_URL)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
long ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
string body = System.Text.Json.JsonSerializer.Serialize(
|
||||
new VoiceAuthBody(roomId, userId.ToString(), publicId, iv, ct),
|
||||
JsonAppSerializerContext.Default.VoiceAuthBody
|
||||
);
|
||||
|
||||
byte[] keyBytes = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(VOICE_KEY));
|
||||
using var hmac = new System.Security.Cryptography.HMACSHA256(keyBytes);
|
||||
string mac = System.Buffers.Text.Base64Url.EncodeToString(
|
||||
hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes($"{ts}.{body}"))
|
||||
);
|
||||
|
||||
using var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Post, $"{MAIN_URL}/_larpix/voice/auth");
|
||||
request.Headers.Add("X-Voice-Ts", ts.ToString());
|
||||
request.Headers.Add("X-Voice-Mac", mac);
|
||||
request.Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
using System.Net.Http.HttpResponseMessage response = await AuthHttp.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine($"Voice auth: main HTTP {(int)response.StatusCode}");
|
||||
return false;
|
||||
}
|
||||
string content = await response.Content.ReadAsStringAsync();
|
||||
if (content.Trim() != "ok")
|
||||
{
|
||||
Console.WriteLine($"Voice auth: main denied ({content.Trim()})");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Voice auth: validation request failed ({ex.Message})");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue