diff --git a/LarpixServer/Account/Requests.cs b/LarpixServer/Account/Requests.cs index d543382..1ee4f03 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, context.Connection.RemoteIpAddress?.ToString() ?? ""); + string auth = await Utils.Auth(id, password, secret); if (auth != Utils.LOGIN_SUCCESS) { await context.Response.WriteAsync(auth); @@ -103,10 +103,9 @@ public class Requests { case "init": { - //check tylko: kwota jest zapisywana w finish po faktycznym utworzeniu konta, - //więc nieudane podejścia jej nie zjadają + //KEM keygen jest kosztowny - limit per IP string initIp = context.Connection.RemoteIpAddress?.ToString() ?? ""; - if (IsRateLimitReached(AccountInitRateLimits, initIp, ACCOUNT_INIT_RATE_LIMIT)) + if (RateLimited(AccountInitRateLimits, initIp, ACCOUNT_INIT_RATE_LIMIT)) { await context.Response.WriteAsync("error:rate.limit"); return; @@ -126,12 +125,7 @@ public class Requests KeyExchangePayload payload = new KeyExchangePayload(); payload.pubX25519 = Convert.ToBase64String(serverInfo.pubX25519); payload.pubMlKem = Convert.ToBase64String(serverInfo.pubMlKem); - //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)); + payload.idKey = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + "\n"; CreateHolder dataHolder = new(); dataHolder.date = DateTimeOffset.UtcNow; @@ -140,6 +134,11 @@ 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( @@ -164,14 +163,6 @@ 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); @@ -324,9 +315,6 @@ 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; } @@ -355,11 +343,18 @@ 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, clientIp); + string auth = await Utils.Auth(id, password, secret); await context.Response.WriteAsync(auth); return; @@ -456,9 +451,8 @@ public class Requests string id = Utils.GetValidIdOrZero(idQuery.ToString()); - //rate limit pobran nonce (IP+konto): jeden atakujący nie zjada budżetu konta innym IP - string clientIp = context.Connection.RemoteIpAddress?.ToString() ?? ""; - if (RateLimited(NonceRateLimits, $"{clientIp}_{id}", NONCE_RATE_LIMIT)) + //rate limit pobran nonce (konto) + if (RateLimited(NonceRateLimits, id, NONCE_RATE_LIMIT)) { string pass = await Utils.GetPassword(id); await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("error:rate.limit", pass)); @@ -517,7 +511,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, context.Connection.RemoteIpAddress?.ToString() ?? ""); + string auth = await Utils.Auth(id, password, secret); if (auth != Utils.LOGIN_SUCCESS) { @@ -604,7 +598,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, context.Connection.RemoteIpAddress?.ToString() ?? ""); + string auth = await Utils.Auth(id, secret, password); if (auth != Utils.LOGIN_SUCCESS) { await context.Response.WriteAsync(auth); @@ -732,7 +726,7 @@ public class Requests AppJsonSerializerContext.Default.Universal2String ); - string auth = await Utils.Auth(id, secret, password, context.Connection.RemoteIpAddress?.ToString() ?? ""); + string auth = await Utils.Auth(id, secret, password); if (auth != Utils.LOGIN_SUCCESS) { @@ -1222,7 +1216,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, context.Connection.RemoteIpAddress?.ToString() ?? ""); + string auth = await Utils.Auth(id, secretStr, password); if (auth != Utils.LOGIN_SUCCESS) { @@ -1282,7 +1276,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, context.Connection.RemoteIpAddress?.ToString() ?? ""); + string auth = await Utils.Auth(id, secretStr, password); if (auth != Utils.LOGIN_SUCCESS) { @@ -1346,7 +1340,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, context.Connection.RemoteIpAddress?.ToString() ?? ""); + string auth = await Utils.Auth(id, secretStr, password); if (auth != Utils.LOGIN_SUCCESS) return; // Don't leak info @@ -1387,7 +1381,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, context.Connection.RemoteIpAddress?.ToString() ?? ""); + string auth = await Utils.Auth(id, secretStr, password); if (auth != Utils.LOGIN_SUCCESS) return; @@ -1420,7 +1414,6 @@ 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 bc4579a..8acd79f 100644 --- a/LarpixServer/Account/Utils.cs +++ b/LarpixServer/Account/Utils.cs @@ -151,23 +151,17 @@ public class Utils return Encoding.UTF8.GetString(await Fs.ReadFile(path)); } - public static async Task Auth(string id, string password, string password2, string clientIp = "") + public static async Task Auth(string id, string password, string password2) { - 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 1f3ff8c..3601ce7 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 / 1h) + //prune pozostałych rate limitów (okna 60s) PruneRateLimits(AuthRateLimits, nowSec); PruneRateLimits(NonceRateLimits, nowSec); PruneRateLimits(KdfSaltRateLimits, nowSec); diff --git a/LarpixServer/Utils/Utils.cs b/LarpixServer/Utils/Utils.cs index e31d63b..67f0bb3 100644 --- a/LarpixServer/Utils/Utils.cs +++ b/LarpixServer/Utils/Utils.cs @@ -30,82 +30,26 @@ public class Utils // Fixed 60s window: (request count, window reset unix time). public static ConcurrentDictionary VoiceAuthLimits = new(); - // Rate limity per IP/konto (okno stałe, domyślnie 60s). Wszystkie konfigurowalne przez .env. + // Rate limity per-minute (okno 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; // failed password verifications per account+IP per minute - public static int NONCE_RATE_LIMIT = 120; // pobrania nonce per IP+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) + 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 - // 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) + // Zwraca true jeśli przekroczono limit. Okno stałe 60s. + public static bool RateLimited(ConcurrentDictionary store, string key, int limit) { if (limit <= 0) return false; // <=0 wyłącza limit - return RecordRateLimitHit(store, key, windowSeconds) > 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. Zwraca licznik okna - public static int RecordRateLimitHit(ConcurrentDictionary store, string key, int windowSeconds = 60) - { 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; - } - - // 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)); - } - - 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 _); - } + return window.count > limit; } public static string DATA_DIR = "./data"; @@ -244,12 +188,19 @@ 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, clientIp); + string auth = await Account.Utils.Auth(id, password, secret); if (auth != Account.Utils.LOGIN_SUCCESS) {