diff --git a/LarpixServer/Account/Requests.cs b/LarpixServer/Account/Requests.cs index 1ee4f03..b64c18d 100644 --- a/LarpixServer/Account/Requests.cs +++ b/LarpixServer/Account/Requests.cs @@ -68,7 +68,7 @@ public class Requests string id = Utils.GetValidIdOrZero(idQuery.ToString()); string password = await Utils.GetPassword(id); secret = await Utils.NonceDecryptBody(id, password, secret); - string auth = await Utils.Auth(id, password, secret); + string auth = await Utils.Auth(id, password, secret, context.Connection.RemoteIpAddress?.ToString() ?? ""); if (auth != Utils.LOGIN_SUCCESS) { await context.Response.WriteAsync(auth); @@ -103,9 +103,10 @@ public class Requests { case "init": { - //KEM keygen jest kosztowny - limit per IP + //check tylko: kwota jest zapisywana w finish po faktycznym utworzeniu konta, + //więc nieudane podejścia jej nie zjadają string initIp = context.Connection.RemoteIpAddress?.ToString() ?? ""; - if (RateLimited(AccountInitRateLimits, initIp, ACCOUNT_INIT_RATE_LIMIT)) + if (IsRateLimitReached(AccountInitRateLimits, initIp, ACCOUNT_INIT_RATE_LIMIT)) { await context.Response.WriteAsync("error:rate.limit"); return; @@ -125,7 +126,12 @@ public class Requests KeyExchangePayload payload = new KeyExchangePayload(); payload.pubX25519 = Convert.ToBase64String(serverInfo.pubX25519); payload.pubMlKem = Convert.ToBase64String(serverInfo.pubMlKem); - payload.idKey = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + "\n"; + //losowy klucz: bez przejścia przez init nie da się odgadnąć idKey, + //więc register bez ważnego init robi tylko tani lookup + do + { + payload.idKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + } while (createHolder.ContainsKey(payload.idKey)); CreateHolder dataHolder = new(); dataHolder.date = DateTimeOffset.UtcNow; @@ -134,11 +140,6 @@ public class Requests dataHolder.KdfSalt = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); payload.kdfSalt = dataHolder.KdfSalt; - while (createHolder.ContainsKey(payload.idKey)) - { - payload.idKey += Random.Shared.Next(0, 10).ToString(); - } - createHolder.TryAdd(payload.idKey, dataHolder); var serializedPayload = JsonSerializer.Serialize( @@ -163,6 +164,14 @@ public class Requests return; } + //register jest jednorazowy w tej sesji: powtórka (albo równoległe żądanie) + //z tym samym idKey nie może drugi raz liczyć KEM + if (Interlocked.CompareExchange(ref entry.registerDone, 1, 0) != 0) + { + await context.Response.WriteAsync("error:account.creation.request.expired"); + return; + } + entry.date = DateTimeOffset.UtcNow; byte[] pubX25519Client = Convert.FromBase64String(serializedBody.pubX25519); @@ -315,6 +324,9 @@ public class Requests userLock.Release(); } + //kwota liczona dopiero po faktycznym utworzeniu konta + RecordRateLimitHit(AccountInitRateLimits, + context.Connection.RemoteIpAddress?.ToString() ?? "", 3600); await context.Response.WriteAsync("success:account.created"); return; } @@ -343,18 +355,11 @@ public class Requests } string id = Utils.GetValidIdOrZero(idQuery.ToString()); - - //rate limit prob logowania (konto+IP) string clientIp = context.Connection.RemoteIpAddress?.ToString() ?? ""; - if (RateLimited(AuthRateLimits, $"{id}:{clientIp}", AUTH_RATE_LIMIT)) - { - await context.Response.WriteAsync("error:rate.limit"); - return; - } string password = await Utils.GetPassword(id); secret = await Utils.NonceDecryptBody(id, password, secret); - string auth = await Utils.Auth(id, password, secret); + string auth = await Utils.Auth(id, password, secret, clientIp); await context.Response.WriteAsync(auth); return; @@ -511,7 +516,7 @@ public class Requests string bodyDecrypted = await Utils.NonceDecryptBody(id, password, body, false); secret = await Utils.NonceDecryptBody(id, password, secret); - string auth = await Utils.Auth(id, password, secret); + string auth = await Utils.Auth(id, password, secret, context.Connection.RemoteIpAddress?.ToString() ?? ""); if (auth != Utils.LOGIN_SUCCESS) { @@ -598,7 +603,7 @@ public class Requests body = await Utils.NonceDecryptBody(id, password, body, false); secret = await Utils.NonceDecryptBody(id, password, secret); - string auth = await Utils.Auth(id, secret, password); + string auth = await Utils.Auth(id, secret, password, context.Connection.RemoteIpAddress?.ToString() ?? ""); if (auth != Utils.LOGIN_SUCCESS) { await context.Response.WriteAsync(auth); @@ -726,7 +731,7 @@ public class Requests AppJsonSerializerContext.Default.Universal2String ); - string auth = await Utils.Auth(id, secret, password); + string auth = await Utils.Auth(id, secret, password, context.Connection.RemoteIpAddress?.ToString() ?? ""); if (auth != Utils.LOGIN_SUCCESS) { @@ -1216,7 +1221,7 @@ public class Requests string target = await Utils.NonceDecryptBody(id, password, targetHeader, false); string secretStr = await Utils.NonceDecryptBody(id, password, secret, false); - string auth = await Utils.Auth(id, secretStr, password); + string auth = await Utils.Auth(id, secretStr, password, context.Connection.RemoteIpAddress?.ToString() ?? ""); if (auth != Utils.LOGIN_SUCCESS) { @@ -1276,7 +1281,7 @@ public class Requests //S9: jednorazowa akcja zapisu - konsumuj nonce (delEntry:false pozwalał //na nieograniczony replay przechwyconej pary secret+body) string secretStr = await Utils.NonceDecryptBody(id, password, secret, true); - string auth = await Utils.Auth(id, secretStr, password); + string auth = await Utils.Auth(id, secretStr, password, context.Connection.RemoteIpAddress?.ToString() ?? ""); if (auth != Utils.LOGIN_SUCCESS) { @@ -1340,7 +1345,7 @@ public class Requests string id = Utils.GetValidIdOrZero(idQuery.ToString()); string password = await Utils.GetPassword(id); string secretStr = await Utils.NonceDecryptBody(id, password, secret, false); - string auth = await Utils.Auth(id, secretStr, password); + string auth = await Utils.Auth(id, secretStr, password, context.Connection.RemoteIpAddress?.ToString() ?? ""); if (auth != Utils.LOGIN_SUCCESS) return; // Don't leak info @@ -1381,7 +1386,7 @@ public class Requests string id = Utils.GetValidIdOrZero(idQuery.ToString()); string password = await Utils.GetPassword(id); string secretStr = await Utils.NonceDecryptBody(id, password, secret, false); - string auth = await Utils.Auth(id, secretStr, password); + string auth = await Utils.Auth(id, secretStr, password, context.Connection.RemoteIpAddress?.ToString() ?? ""); if (auth != Utils.LOGIN_SUCCESS) return; @@ -1414,6 +1419,7 @@ public class Requests public class CreateHolder { public DateTimeOffset date; + public int registerDone; public string name; public string pass; public string captcha; diff --git a/LarpixServer/Account/Utils.cs b/LarpixServer/Account/Utils.cs index 8acd79f..bc4579a 100644 --- a/LarpixServer/Account/Utils.cs +++ b/LarpixServer/Account/Utils.cs @@ -151,17 +151,23 @@ public class Utils return Encoding.UTF8.GetString(await Fs.ReadFile(path)); } - public static async Task Auth(string id, string password, string password2) + public static async Task Auth(string id, string password, string password2, string clientIp = "") { + if (IsAuthRateLimited(id, clientIp)) + { + return "error:invalid.password"; + } if (!Fs.Exists($"{ACCOUNTS_DATA_DIR}/{id}")) { return "error:invalid.username.or.password"; } if (password != password2) { + RecordAuthFailure(id, clientIp); return "error:invalid.password"; } + RecordAuthSuccess(id, clientIp); await UpdateLastLogin(id); return LOGIN_SUCCESS; } diff --git a/LarpixServer/Program.cs b/LarpixServer/Program.cs index 3601ce7..1f3ff8c 100644 --- a/LarpixServer/Program.cs +++ b/LarpixServer/Program.cs @@ -41,7 +41,7 @@ public class Program { if (entry.Value.resetAt <= nowSec) VoiceAuthLimits.TryRemove(entry.Key, out _); } - //prune pozostałych rate limitów (okna 60s) + //prune pozostałych rate limitów (okna 60s / 1h) PruneRateLimits(AuthRateLimits, nowSec); PruneRateLimits(NonceRateLimits, nowSec); PruneRateLimits(KdfSaltRateLimits, nowSec); diff --git a/LarpixServer/Utils/Utils.cs b/LarpixServer/Utils/Utils.cs index 67f0bb3..a99f96f 100644 --- a/LarpixServer/Utils/Utils.cs +++ b/LarpixServer/Utils/Utils.cs @@ -30,26 +30,85 @@ public class Utils // Fixed 60s window: (request count, window reset unix time). public static ConcurrentDictionary VoiceAuthLimits = new(); - // Rate limity per-minute (okno 60s). Wszystkie konfigurowalne przez .env. + // Rate limity per IP/konto (okno stałe, domyślnie 60s). Wszystkie konfigurowalne przez .env. public static ConcurrentDictionary AuthRateLimits = new(); public static ConcurrentDictionary NonceRateLimits = new(); public static ConcurrentDictionary KdfSaltRateLimits = new(); public static ConcurrentDictionary AccountInitRateLimits = new(); - public static int AUTH_RATE_LIMIT = 10; // próby logowania /auth + /ws na konto+IP - public static int NONCE_RATE_LIMIT = 240; // pobrania nonce per konto - public static int KDFSALT_RATE_LIMIT = 60; // pobrania soli per IP - public static int ACCOUNT_INIT_RATE_LIMIT = 10; // inicjalizacje rejestracji (KEM keygen) per IP + public static int AUTH_RATE_LIMIT = 10; // failed password verifications per account+IP per minute + public static int NONCE_RATE_LIMIT = 240; // pobrania nonce per konto per minute + public static int KDFSALT_RATE_LIMIT = 60; // pobrania soli per IP per minute + public static int ACCOUNT_INIT_RATE_LIMIT = 4; // utworzone konta per IP per hour (check w init, zapis w finish) - // Zwraca true jeśli przekroczono limit. Okno stałe 60s. - public static bool RateLimited(ConcurrentDictionary store, string key, int limit) + // Zwraca true jeśli przekroczono limit. Okno stałe, domyślnie 60s. + public static bool RateLimited(ConcurrentDictionary store, string key, int limit, int windowSeconds = 60) { if (limit <= 0) return false; // <=0 wyłącza limit long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var window = store.AddOrUpdate(key, + (1, nowSec + windowSeconds), + (_, old) => old.resetAt <= nowSec ? (1, nowSec + windowSeconds) : (old.count + 1, old.resetAt)); + return window.count > limit; + } + + //Sprawdzenie bez inkrementacji: true jeśli klucz osiągnął już limit w bieżącym oknie + public static bool IsRateLimitReached(ConcurrentDictionary store, string key, int limit) + { + if (limit <= 0) return false; + if (!store.TryGetValue(key, out var entry)) return false; + + long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (entry.resetAt <= nowSec) + { + store.TryRemove(key, out _); + return false; + } + return entry.count >= limit; + } + + //Zapis jednego zdarzenia; okno startuje przy pierwszym zapisie + public static void RecordRateLimitHit(ConcurrentDictionary store, string key, int windowSeconds = 60) + { + long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + store.AddOrUpdate(key, + (1, nowSec + windowSeconds), + (_, old) => old.resetAt <= nowSec ? (1, nowSec + windowSeconds) : (old.count + 1, old.resetAt)); + } + + // Failed password verifications only, keyed "{id}:{ip}". A success clears the + // counter unless the limit is already reached; then the 60s window must expire. + public static bool IsAuthRateLimited(string id, string clientIp) + { + if (AUTH_RATE_LIMIT <= 0) return false; + string key = $"{id}:{clientIp}"; + if (!AuthRateLimits.TryGetValue(key, out var entry)) return false; + + long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (entry.resetAt <= nowSec) + { + AuthRateLimits.TryRemove(key, out _); + return false; + } + return entry.count >= AUTH_RATE_LIMIT; + } + + public static void RecordAuthFailure(string id, string clientIp) + { + if (AUTH_RATE_LIMIT <= 0) return; + long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + AuthRateLimits.AddOrUpdate($"{id}:{clientIp}", (1, nowSec + 60), (_, old) => old.resetAt <= nowSec ? (1, nowSec + 60) : (old.count + 1, old.resetAt)); - return window.count > limit; + } + + public static void RecordAuthSuccess(string id, string clientIp) + { + string key = $"{id}:{clientIp}"; + if (AuthRateLimits.TryGetValue(key, out var entry) && entry.count < AUTH_RATE_LIMIT) + { + AuthRateLimits.TryRemove(key, out _); + } } public static string DATA_DIR = "./data"; @@ -188,19 +247,12 @@ public class Utils } string id = Account.Utils.GetValidIdOrZero(authParams.string1); - - //rate limit prób autoryzacji WS (konto+IP) - wspólny zasobnik z /auth string clientIp = context.Connection.RemoteIpAddress?.ToString() ?? ""; - if (RateLimited(AuthRateLimits, $"{id}:{clientIp}", AUTH_RATE_LIMIT)) - { - await webSocket.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.PolicyViolation, "Rate limited", CancellationToken.None); - return; - } string password = await Account.Utils.GetPassword(id); string secret = await Account.Utils.NonceDecryptBody(id, password, authParams.string2); - string auth = await Account.Utils.Auth(id, password, secret); + string auth = await Account.Utils.Auth(id, password, secret, clientIp); if (auth != Account.Utils.LOGIN_SUCCESS) {