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
|
|
@ -13,6 +13,29 @@ public class Program
|
||||||
public static async Task Main(string[] args)
|
public static async Task Main(string[] args)
|
||||||
{
|
{
|
||||||
await LoadEnv();
|
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);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Services.AddHostedService<BackgroundDelete>();
|
builder.Services.AddHostedService<BackgroundDelete>();
|
||||||
builder.Logging.ClearProviders();
|
builder.Logging.ClearProviders();
|
||||||
|
|
@ -95,6 +118,9 @@ public class Program
|
||||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
case "/_larpix/voice/auth":
|
||||||
|
await LarpixServer.Room.Shared.InternalVoiceAuth(context);
|
||||||
|
return;
|
||||||
case "/_larpix/serverinfo":
|
case "/_larpix/serverinfo":
|
||||||
ServerInfo serverInfo = new ServerInfo();
|
ServerInfo serverInfo = new ServerInfo();
|
||||||
serverInfo.domain = DOMAIN;
|
serverInfo.domain = DOMAIN;
|
||||||
|
|
|
||||||
|
|
@ -653,6 +653,29 @@ public class Shared
|
||||||
else return "error:no.voice.servers";
|
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");
|
string[] members = Fs.ReadDirectory($"{GetRoomPath(roomType, roomId)}/members");
|
||||||
foreach(string memberWD in members)
|
foreach(string memberWD in members)
|
||||||
{
|
{
|
||||||
|
|
@ -662,7 +685,157 @@ public class Shared
|
||||||
_ = Utils.Utils.SendWsMessage(memberId, $"call_update:{roomId}:{callState}");
|
_ = 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<string> VoiceLeave(string id, string body)
|
public static async Task<string> VoiceLeave(string id, string body)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,19 @@ public class Utils
|
||||||
public static int PORT = 8090;
|
public static int PORT = 8090;
|
||||||
public static ConcurrentBag<string> VOICE_IPS = new();
|
public static ConcurrentBag<string> 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<string, VoiceTicketRecord> 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<string, (int count, long resetAt)> VoiceAuthLimits = new();
|
||||||
|
|
||||||
public static string DATA_DIR = "./data";
|
public static string DATA_DIR = "./data";
|
||||||
public static string ROOMS_DIR = "./data/rooms";
|
public static string ROOMS_DIR = "./data/rooms";
|
||||||
public static string ACCOUNTS_DIR = "./data/accounts";
|
public static string ACCOUNTS_DIR = "./data/accounts";
|
||||||
|
|
@ -34,6 +47,9 @@ public class Utils
|
||||||
public static int MAX_BODY_SIZE = 20971520; //20mb //2621440; //2.5mb
|
public static int MAX_BODY_SIZE = 20971520; //20mb //2621440; //2.5mb
|
||||||
public static int ACTION_RATE_LIMIT = 120;
|
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 PRIVATE_STORAGE_LIMIT = 5242880;
|
||||||
public static int PUBLIC_STORAGE_LIMIT = 10485760;
|
public static int PUBLIC_STORAGE_LIMIT = 10485760;
|
||||||
|
|
||||||
|
|
@ -324,6 +340,12 @@ public class Utils
|
||||||
ACTION_RATE_LIMIT = parsedLimit;
|
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();
|
VOICE_IPS.Clear();
|
||||||
|
|
||||||
port = Environment.GetEnvironmentVariable("VOICE_IPS");
|
port = Environment.GetEnvironmentVariable("VOICE_IPS");
|
||||||
|
|
@ -345,6 +367,7 @@ public class Utils
|
||||||
|
|
||||||
DOMAIN = Environment.GetEnvironmentVariable("DOMAIN") ?? DOMAIN;
|
DOMAIN = Environment.GetEnvironmentVariable("DOMAIN") ?? DOMAIN;
|
||||||
SERVER_KEY = Environment.GetEnvironmentVariable("SERVER_KEY") ?? SERVER_KEY;
|
SERVER_KEY = Environment.GetEnvironmentVariable("SERVER_KEY") ?? SERVER_KEY;
|
||||||
|
VOICE_KEY = Environment.GetEnvironmentVariable("VOICE_KEY") ?? VOICE_KEY;
|
||||||
DATA_DIR = Environment.GetEnvironmentVariable("DATA_DIR") ?? DATA_DIR;
|
DATA_DIR = Environment.GetEnvironmentVariable("DATA_DIR") ?? DATA_DIR;
|
||||||
OWNER_CONTACT = Environment.GetEnvironmentVariable("OWNER_CONTACT") ?? OWNER_CONTACT;
|
OWNER_CONTACT = Environment.GetEnvironmentVariable("OWNER_CONTACT") ?? OWNER_CONTACT;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,14 @@ using System.Text.Json.Serialization;
|
||||||
namespace LarpixVoice;
|
namespace LarpixVoice;
|
||||||
|
|
||||||
[JsonSerializable(typeof(string[]))]
|
[JsonSerializable(typeof(string[]))]
|
||||||
|
[JsonSerializable(typeof(VoiceAuthBody))]
|
||||||
internal partial class JsonAppSerializerContext : JsonSerializerContext
|
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 ();
|
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 TotalBytesSent = 0;
|
||||||
public static ulong TotalBytesReceived = 0;
|
public static ulong TotalBytesReceived = 0;
|
||||||
private static ulong CurrentBandwidthKps = 0;
|
private static ulong CurrentBandwidthKps = 0;
|
||||||
|
|
@ -25,6 +33,12 @@ public class Program
|
||||||
Interlocked.Exchange(ref TotalBytesReceived, 0);
|
Interlocked.Exchange(ref TotalBytesReceived, 0);
|
||||||
Interlocked.Exchange(ref TotalBytesSent, 0);
|
Interlocked.Exchange(ref TotalBytesSent, 0);
|
||||||
CurrentBandwidthKps = (sentDelta + receivedDelta);
|
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());
|
await context.Response.WriteAsync(Interlocked.Read(ref CurrentBandwidthKps).ToString());
|
||||||
return;
|
return;
|
||||||
case "/room/users":
|
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();
|
string roomId = context.Request.Query["room"].ToString();
|
||||||
context.Response.ContentType = "application/json";
|
context.Response.ContentType = "application/json";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ public class Requests
|
||||||
|
|
||||||
string roomId = context.Request.Query["room"].ToString();
|
string roomId = context.Request.Query["room"].ToString();
|
||||||
string userIdStr = context.Request.Query["userId"].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))
|
if (string.IsNullOrEmpty(roomId) || !ulong.TryParse(userIdStr, out ulong clientId))
|
||||||
{
|
{
|
||||||
|
|
@ -27,6 +26,56 @@ public class Requests
|
||||||
}
|
}
|
||||||
|
|
||||||
using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
|
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>());
|
var roomClients = Rooms.GetOrAdd(roomId, _ => new ConcurrentDictionary<ulong, Session>());
|
||||||
|
|
||||||
if (roomClients.TryGetValue(clientId, out var oldSession) && oldSession.Socket.State == WebSocketState.Open)
|
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;
|
roomClients[clientId] = currentSession;
|
||||||
|
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
|
|
@ -67,6 +116,7 @@ public class Requests
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Big buffer only for authenticated media traffic.
|
||||||
var receiveBuffer = ArrayPool<byte>.Shared.Rent(1024 * 1024);
|
var receiveBuffer = ArrayPool<byte>.Shared.Rent(1024 * 1024);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -121,4 +171,16 @@ public class Requests
|
||||||
if (roomClients.IsEmpty) Rooms.TryRemove(roomId, out _);
|
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,6 +5,18 @@ public class Utils
|
||||||
public static int PORT = 8091;
|
public static int PORT = 8091;
|
||||||
public static int MAIN_PORT = 8090;
|
public static int MAIN_PORT = 8090;
|
||||||
public static string SERVER_KEY = "SecretThingOMGSoHardToGuess";
|
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()
|
public static void LoadEnv()
|
||||||
{
|
{
|
||||||
|
|
@ -44,5 +56,60 @@ public class Utils
|
||||||
MAIN_PORT = int.Parse(port);
|
MAIN_PORT = int.Parse(port);
|
||||||
}
|
}
|
||||||
SERVER_KEY = Environment.GetEnvironmentVariable("SERVER_KEY") ?? SERVER_KEY;
|
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