diff --git a/LarpixServer/Program.cs b/LarpixServer/Program.cs index 2013d8d..3ac713a 100644 --- a/LarpixServer/Program.cs +++ b/LarpixServer/Program.cs @@ -13,6 +13,29 @@ public class Program public static async Task Main(string[] args) { await LoadEnv(); + + // Voice ticket sweeper: issued tickets live for 60s; remove expired + // ones a minute later so nothing lingers in memory. Also prunes stale + // per-IP auth rate-limit counters (they are written pre-HMAC-check, + // so without pruning any scanner could grow the map forever). + _ = Task.Run(async () => + { + while (true) + { + await Task.Delay(30000); + long cutoff = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - 120; + foreach (var entry in VoiceTickets) + { + if (entry.Value.Exp < cutoff) VoiceTickets.TryRemove(entry.Key, out _); + } + long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + foreach (var entry in VoiceAuthLimits) + { + if (entry.Value.resetAt <= nowSec) VoiceAuthLimits.TryRemove(entry.Key, out _); + } + } + }); + var builder = WebApplication.CreateBuilder(args); builder.Services.AddHostedService(); builder.Logging.ClearProviders(); @@ -95,6 +118,9 @@ public class Program context.Response.StatusCode = StatusCodes.Status400BadRequest; } return; + case "/_larpix/voice/auth": + await LarpixServer.Room.Shared.InternalVoiceAuth(context); + return; case "/_larpix/serverinfo": ServerInfo serverInfo = new ServerInfo(); serverInfo.domain = DOMAIN; diff --git a/LarpixServer/Room/Shared.cs b/LarpixServer/Room/Shared.cs index ed0d90f..e2714e2 100644 --- a/LarpixServer/Room/Shared.cs +++ b/LarpixServer/Room/Shared.cs @@ -647,12 +647,35 @@ public class Shared catch {} } - if (string.IsNullOrEmpty(bestIp)) + if (string.IsNullOrEmpty(bestIp)) { if (VOICE_IPS.Count > 0) bestIp = VOICE_IPS.First(); else return "error:no.voice.servers"; } + // Single-use voice ticket: binds this user to this room for one WS + // connection, valid for a minute. The client never sends the ticket in + // cleartext: it encrypts `proof` with `connKey` (both delivered here, + // over the encrypted API) and the voice server relays the blob to + // /_larpix/voice/auth, where it is verified and consumed atomically. + if (string.IsNullOrEmpty(VOICE_KEY)) return "error:voice.not.configured"; + + long ticketExp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 60; + // publicId: 48 random bytes = exactly 64 base64url chars (the voice + // server's frame check accepts 1-64). connKey MUST stay 32 bytes - it + // is the AES-GCM key (128/192/256 bit only). + string publicId = System.Buffers.Text.Base64Url.EncodeToString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(48)); + string connKey = System.Buffers.Text.Base64Url.EncodeToString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); + string proof = System.Buffers.Text.Base64Url.EncodeToString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); + + if (!VoiceTickets.TryAdd(publicId, new VoiceTicketRecord( + System.Buffers.Text.Base64Url.DecodeFromChars(connKey), + System.Buffers.Text.Base64Url.DecodeFromChars(proof), + roomId, id, ticketExp))) + { + return "error:voice.not.configured"; // 48 random bytes colliding - practically impossible + } + string[] members = Fs.ReadDirectory($"{GetRoomPath(roomType, roomId)}/members"); foreach(string memberWD in members) { @@ -662,7 +685,157 @@ public class Shared _ = Utils.Utils.SendWsMessage(memberId, $"call_update:{roomId}:{callState}"); } } - return $"success:{bestIp}"; + return $"success:{bestIp}|{publicId}|{connKey}|{proof}"; + } + + // Internal endpoint for LarpixVoice: validates (and atomically consumes) a + // voice ticket blob. Authenticated with HMAC(VOICE_KEY) over "ts.body"; + // body itself is AES-GCM(connKey)-encrypted by the client, so the voice + // server only ever relays opaque bytes. Fails closed on any problem. + public static async Task InternalVoiceAuth(HttpContext context) + { + context.Response.ContentType = "text/plain"; + + string ip = context.Connection.RemoteIpAddress?.ToString() ?? ""; + long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var window = VoiceAuthLimits.AddOrUpdate(ip, + (1, nowSec + 60), + (_, old) => old.resetAt <= nowSec ? (1, nowSec + 60) : (old.count + 1, old.resetAt)); + if (window.count > VOICE_AUTH_RATE_LIMIT) + { + Console.WriteLine($"Voice auth deny [{ip}]: rate limited"); + context.Response.StatusCode = 429; + await context.Response.WriteAsync("deny:rate limited"); + return; + } + + if (string.IsNullOrEmpty(VOICE_KEY)) + { + Console.WriteLine("Voice auth deny: VOICE_KEY not configured"); + await context.Response.WriteAsync("deny:no key"); + return; + } + + if (!context.Request.Headers.TryGetValue("X-Voice-Ts", out var tsHeader) || + !context.Request.Headers.TryGetValue("X-Voice-Mac", out var macHeader) || + !long.TryParse(tsHeader, out long ts) || Math.Abs(nowSec - ts) > 30) + { + Console.WriteLine($"Voice auth deny [{ip}]: missing/stale ts or mac"); + await context.Response.WriteAsync("deny:stale ts"); + return; + } + + using StreamReader reader = new StreamReader(context.Request.Body); + string rawBody = await reader.ReadToEndAsync(); + if (rawBody.Length == 0 || rawBody.Length > 4096) + { + await context.Response.WriteAsync("deny:parse"); + return; + } + + byte[] keyBytes = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(VOICE_KEY)); + using var hmac = new System.Security.Cryptography.HMACSHA256(keyBytes); + byte[] expectedMac = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{ts}.{rawBody}")); + byte[] gotMac; + try { gotMac = System.Buffers.Text.Base64Url.DecodeFromChars(macHeader.ToString()); } + catch (FormatException) + { + Console.WriteLine($"Voice auth deny [{ip}]: bad mac encoding"); + await context.Response.WriteAsync("deny:bad mac encoding"); + return; + } + if (!System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(expectedMac, gotMac)) + { + Console.WriteLine($"Voice auth deny [{ip}]: HMAC mismatch (VOICE_KEY differs between servers?)"); + await context.Response.WriteAsync("deny:hmac mismatch"); + return; + } + + try + { + using JsonDocument doc = JsonDocument.Parse(rawBody); + string room = doc.RootElement.GetProperty("room").GetString() ?? ""; + string userId = doc.RootElement.GetProperty("userId").GetString() ?? ""; + string publicId = doc.RootElement.GetProperty("publicId").GetString() ?? ""; + string iv = doc.RootElement.GetProperty("iv").GetString() ?? ""; + string ct = doc.RootElement.GetProperty("ct").GetString() ?? ""; + + if (!VoiceTickets.TryGetValue(publicId, out var record)) + { + Console.WriteLine($"Voice auth deny [{ip}]: unknown ticket (wrong main server, restart, or >60s old)"); + await context.Response.WriteAsync("deny:unknown ticket"); + return; + } + if (record.Exp < nowSec || record.RoomId != room || record.UserId != userId) + { + Console.WriteLine($"Voice auth deny [{ip}]: binding/expiry mismatch (room={room} user={userId})"); + await context.Response.WriteAsync("deny:unknown ticket"); + return; + } + + byte[] proofBytes; + try + { + using var aes = new System.Security.Cryptography.AesGcm(record.ConnKey, 16); + byte[] ivBytes; + byte[] ctBytes; + try + { + ivBytes = System.Buffers.Text.Base64Url.DecodeFromChars(iv); + ctBytes = System.Buffers.Text.Base64Url.DecodeFromChars(ct); + } + catch (FormatException) + { + Console.WriteLine($"Voice auth deny [{ip}]: bad base64url in iv/ct"); + await context.Response.WriteAsync("deny:bad encoding"); + return; + } + // ct is ciphertext||tag(16B), matching WebCrypto AES-GCM output. + if (ivBytes.Length != 12 || ctBytes.Length < 16) + { + Console.WriteLine($"Voice auth deny [{ip}]: bad iv/ct lengths (iv={ivBytes.Length}, ct={ctBytes.Length})"); + await context.Response.WriteAsync("deny:bad encoding"); + return; + } + byte[] ctBody = new byte[ctBytes.Length - 16]; + Array.Copy(ctBytes, ctBody, ctBody.Length); + byte[] plain = new byte[ctBody.Length]; + // Decrypt(nonce, ciphertext, tag, plaintext) - the old call + // Decrypt(iv, ct, null, plain) bound "null" to the TAG slot + // and threw on every request. + aes.Decrypt(ivBytes, ctBody, ctBytes.AsSpan(ctBody.Length), plain); + proofBytes = plain; + } + catch (System.Security.Cryptography.AuthenticationTagMismatchException) + { + Console.WriteLine($"Voice auth deny [{ip}]: gcm tag mismatch (blob not encrypted with this ticket's connKey)"); + await context.Response.WriteAsync("deny:binding/expiry"); + return; + } + + if (!proofBytes.AsSpan().SequenceEqual(record.Proof)) + { + Console.WriteLine($"Voice auth deny [{ip}]: proof mismatch (client blob corrupted)"); + await context.Response.WriteAsync("deny:gcm decrypt"); + return; + } + + // Atomic single-use claim - concurrent replays of the same blob + // lose this race and get "deny". + if (!VoiceTickets.TryRemove(publicId, out _)) + { + Console.WriteLine($"Voice auth deny [{ip}]: ticket already used"); + await context.Response.WriteAsync("deny:already used"); + return; + } + + await context.Response.WriteAsync("ok"); + } + catch (Exception ex) + { + Console.WriteLine($"Voice auth error [{ip}]: {ex}"); + await context.Response.WriteAsync("deny:internal error"); + } } public static async Task VoiceLeave(string id, string body) diff --git a/LarpixServer/Utils/Utils.cs b/LarpixServer/Utils/Utils.cs index 2a55726..d4fc7f5 100644 --- a/LarpixServer/Utils/Utils.cs +++ b/LarpixServer/Utils/Utils.cs @@ -16,6 +16,19 @@ public class Utils public static int PORT = 8090; public static ConcurrentBag VOICE_IPS = new(); + + // Shared with LarpixVoice: HMAC key used to sign voice-join tickets. + public static string VOICE_KEY = ""; + + public sealed record VoiceTicketRecord(byte[] ConnKey, byte[] Proof, string RoomId, string UserId, long Exp); + + // Outstanding (issued, not yet consumed) voice tickets: publicId -> record. + // Consumed atomically by /_larpix/voice/auth; sweeper removes expired ones. + public static ConcurrentDictionary VoiceTickets = new(); + + // Per-IP rate limit state for the internal voice auth endpoint. + // Fixed 60s window: (request count, window reset unix time). + public static ConcurrentDictionary VoiceAuthLimits = new(); public static string DATA_DIR = "./data"; public static string ROOMS_DIR = "./data/rooms"; @@ -33,6 +46,9 @@ public class Utils public static int MAX_BODY_SIZE = 20971520; //20mb //2621440; //2.5mb public static int ACTION_RATE_LIMIT = 120; + + // All rate limits below are requests per minute (fixed 60s window). + public static int VOICE_AUTH_RATE_LIMIT = 3600; public static int PRIVATE_STORAGE_LIMIT = 5242880; public static int PUBLIC_STORAGE_LIMIT = 10485760; @@ -323,6 +339,12 @@ public class Utils { ACTION_RATE_LIMIT = parsedLimit; } + + string? voiceAuthRateLimitStr = Environment.GetEnvironmentVariable("VOICE_AUTH_RATE_LIMIT"); + if (voiceAuthRateLimitStr != null && int.TryParse(voiceAuthRateLimitStr, out int parsedVoiceAuthLimit)) + { + VOICE_AUTH_RATE_LIMIT = parsedVoiceAuthLimit; + } VOICE_IPS.Clear(); @@ -345,6 +367,7 @@ public class Utils DOMAIN = Environment.GetEnvironmentVariable("DOMAIN") ?? DOMAIN; SERVER_KEY = Environment.GetEnvironmentVariable("SERVER_KEY") ?? SERVER_KEY; + VOICE_KEY = Environment.GetEnvironmentVariable("VOICE_KEY") ?? VOICE_KEY; DATA_DIR = Environment.GetEnvironmentVariable("DATA_DIR") ?? DATA_DIR; OWNER_CONTACT = Environment.GetEnvironmentVariable("OWNER_CONTACT") ?? OWNER_CONTACT; diff --git a/LarpixVoice/JsonAppSerializerContext.cs b/LarpixVoice/JsonAppSerializerContext.cs index 0376cca..0282a04 100644 --- a/LarpixVoice/JsonAppSerializerContext.cs +++ b/LarpixVoice/JsonAppSerializerContext.cs @@ -3,6 +3,14 @@ using System.Text.Json.Serialization; namespace LarpixVoice; [JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(VoiceAuthBody))] internal partial class JsonAppSerializerContext : JsonSerializerContext { -} \ No newline at end of file +} + +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); \ No newline at end of file diff --git a/LarpixVoice/Program.cs b/LarpixVoice/Program.cs index 3916d7d..8f33851 100644 --- a/LarpixVoice/Program.cs +++ b/LarpixVoice/Program.cs @@ -6,6 +6,14 @@ public class Program { public static ConcurrentDictionary> 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 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"; diff --git a/LarpixVoice/Requests.cs b/LarpixVoice/Requests.cs index 9c78179..a73d909 100644 --- a/LarpixVoice/Requests.cs +++ b/LarpixVoice/Requests.cs @@ -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 "..". + // 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.Shared.Rent(2048); + string ticket; + using (var authCts = new CancellationTokenSource(TimeSpan.FromSeconds(5))) + { + WebSocketReceiveResult firstFrame; + try + { + firstFrame = await webSocket.ReceiveAsync(new ArraySegment(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.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()); 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(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.Shared.Rent(1024 * 1024); try { @@ -80,10 +130,10 @@ public class Requests result = await webSocket.ReceiveAsync( new ArraySegment(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 + { + } + } } \ No newline at end of file diff --git a/LarpixVoice/Utils.cs b/LarpixVoice/Utils.cs index a8c109f..20cbaa2 100644 --- a/LarpixVoice/Utils.cs +++ b/LarpixVoice/Utils.cs @@ -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 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; + } } } \ No newline at end of file