massive security update
All checks were successful
Server Build / publish (push) Successful in 29s
Voice Build / publish (push) Successful in 25s

This commit is contained in:
olcxja 2026-08-24 19:22:38 +02:00
commit 6dc145a2c8
11 changed files with 739 additions and 225 deletions

View file

@ -1,6 +1,7 @@
using SkiaSharp;
using System;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
namespace LarpixServer.Account;
@ -20,7 +21,8 @@ public class Captcha
for (int i = 0; i < charCount; i++)
{
captchaSpan[i] = Chars[Random.Shared.Next(Chars.Length)];
//tekst captchy MUSI pochodzić z CSPRNG - Random.Shared (MT19937) jest przewidywalny
captchaSpan[i] = Chars[RandomNumberGenerator.GetInt32(Chars.Length)];
charColors[i] = new SKColor(
(byte)Random.Shared.Next(100, 256),
(byte)Random.Shared.Next(100, 256),

View file

@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Numerics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LarpixServer.Filesystem;
@ -36,6 +37,18 @@ public class Requests
return false;
}
//wywoływane przez sweeper w Program.cs - usuwa wygasłe okna
public static void PruneActionRateLimits(long nowSec)
{
foreach (var entry in actionRateLimits)
{
if (entry.Value.resetTime <= DateTimeOffset.FromUnixTimeSeconds(nowSec))
{
actionRateLimits.TryRemove(entry.Key, out _);
}
}
}
public static SemaphoreSlim createLock = new SemaphoreSlim(1, 1);
@ -90,6 +103,14 @@ public class Requests
{
case "init":
{
//KEM keygen jest kosztowny - limit per IP
string initIp = context.Connection.RemoteIpAddress?.ToString() ?? "";
if (RateLimited(AccountInitRateLimits, initIp, ACCOUNT_INIT_RATE_LIMIT))
{
await context.Response.WriteAsync("error:rate.limit");
return;
}
foreach (var kvp in createHolder) // czyszczenie nieaktywnych od 2 minut requestow
{
if (kvp.Value.date < DateTimeOffset.UtcNow.AddMinutes(-2))
@ -106,15 +127,17 @@ public class Requests
payload.pubMlKem = Convert.ToBase64String(serverInfo.pubMlKem);
payload.idKey = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + "\n";
while (createHolder.ContainsKey(payload.idKey))
{
payload.idKey += Random.Shared.Next(0, 10).ToString();
}
CreateHolder dataHolder = new();
dataHolder.date = DateTimeOffset.UtcNow;
dataHolder.privX25519Server = serverInfo.privX25519;
dataHolder.privMlKemServer = serverInfo.privMlKem;
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);
@ -271,6 +294,11 @@ public class Requests
Encoding.UTF8.GetBytes(entry.name));
await Fs.WriteFile($"{ACCOUNTS_DATA_DIR}/{id.ToString()}/secret",
Encoding.UTF8.GetBytes(entry.pass));
if (!string.IsNullOrEmpty(entry.KdfSalt))
{
await Fs.WriteFile($"{ACCOUNTS_DATA_DIR}/{id.ToString()}/kdfsalt",
Encoding.UTF8.GetBytes(entry.KdfSalt));
}
}
catch (Exception)
{
@ -314,6 +342,15 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
}
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);
@ -347,14 +384,9 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
return;
}
if (!Utils.IsUserLocal(username, out string domain)) //federation
if (!Utils.IsUserLocal(username, out string domain)) //federation wylaczona (brak uwierzytelnienia)
{
username = Utils.GetIdFromUsernameWD(username.ToString());
string remoteId = await Federation.Sender.GetUserId(username, domain);
if (remoteId != "0" && !string.IsNullOrEmpty(remoteId))
{
await context.Response.WriteAsync(remoteId + $":{domain}");
}
await context.Response.WriteAsync("error:federation.disabled");
return;
}
username = Utils.GetIdFromUsernameWD(username.ToString());
@ -381,6 +413,34 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
}
// Zwraca sól KDF konta: "P:<base64>" = PBKDF2, "S:" = brak soli (konto nielogowalne nowym klientem).
public static async Task GetKdfSalt(HttpContext context, IQueryCollection query)
{
if (!query.TryGetValue("id", out var idQuery))
{
return;
}
//rate limit per IP
string clientIp = context.Connection.RemoteIpAddress?.ToString() ?? "";
if (RateLimited(KdfSaltRateLimits, clientIp, KDFSALT_RATE_LIMIT))
{
context.Response.StatusCode = 429;
return;
}
string id = Utils.GetValidIdOrZero(idQuery.ToString());
string path = $"{ACCOUNTS_DATA_DIR}/{id}/kdfsalt";
if (!Fs.Exists(path))
{
await context.Response.WriteAsync("S:");
return;
}
string salt = Encoding.UTF8.GetString(await Fs.ReadFile(path));
await context.Response.WriteAsync("P:" + salt);
}
public static async Task NextNonce(HttpContext context, Func<Task> next, IQueryCollection query)
{
if (!query.TryGetValue("id", out var idQuery))
@ -389,6 +449,15 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
}
string id = Utils.GetValidIdOrZero(idQuery.ToString());
//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));
return;
}
string plainPass = await Utils.GetPassword(id);
foreach (var kvp in nonceHolder) //clearowanie nieuzytych nonce
{
@ -398,7 +467,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
}
else if (kvp.Key == id)
{
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(kvp.Value.Item1,
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(kvp.Value.Item1,
plainPass));
return;
}
@ -406,7 +475,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
string nonce = Encryption.Encryption.GetRandomString(64);
nonceHolder.TryAdd(id, (nonce, DateTimeOffset.UtcNow));
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(nonce,
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(nonce,
plainPass));
return;
}
@ -524,16 +593,8 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
return;
}
SemaphoreSlim userLock = Utils.GetUserLock(id.ToString());
await userLock.WaitAsync();
try
{
//SetUsername sam zarządza lockami (createLock -> userLock) - patrz Account.Utils.SetUsername
await context.Response.WriteAsync(await Utils.SetUsername(id, body));
}
finally
{
userLock.Release();
}
return;
}
@ -657,29 +718,16 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
if (auth != Utils.LOGIN_SUCCESS)
{
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(auth, secret));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(auth, secret));
return;
}
string[] actionRequests = {
"user/dm/invite", "user/dm/invite/revoke", "user/dm/invite/decline", "user/dm/create",
"dm/message/send", "dm/message/delete", "dm/message/react", "dm/key/update", "dm/message/pin/toggle",
"dm/message/react/add", "dm/message/react/remove",
"user/storage/public/update", "user/storage/private/update", "dm/read",
"group/create", "user/group/list", "group/info", "group/settings/update",
"user/invite/accept", "user/invite/decline", "user/invite/revoke", "user/dm/list", "user/group/list",
"user/storage/public/update", "user/storage/private/update", "group/storage/public/update", "group/storage/private/update", "group/storage/private/getentry", "dm/read", "group/read",
"dm/message/send", "dm/message/delete", "dm/message/react/add", "dm/message/react/remove", "dm/message/pin/toggle",
"group/messages/get", "group/messages/pinned"
};
if (System.Array.IndexOf(actionRequests, serializedBody.string1) >= 0)
{
//limit wszystkich żądań encryptedrequest per konto
if (IsActionRateLimited(id))
{
await context.Response.WriteAsync(Encryption.Encryption.EncryptString("error:rate.limit", password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("error:rate.limit", password));
return;
}
}
SemaphoreSlim userLock = Utils.GetUserLock(id);
await userLock.WaitAsync();
@ -689,25 +737,25 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
{
case "user/invites/sent":
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Utils.GetUserSentInvites(id)
, password));
break;
case "user/invites/received":
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Utils.GetUserReceivedInvites(id)
, password));
break;
case "user/key/update":
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await UpdateUserKeys(id, serializedBody.string2)
, password));
break;
case "user/key/get":
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Utils.GetUserKeys(id)
, password));
break;
@ -718,7 +766,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal2String
);
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Utils.UpdateUserPublicStorageEntry(id, storageUpdate.string1, storageUpdate.string2)
, password));
break;
@ -730,7 +778,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal2String
);
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Utils.UpdateUserPrivateStorageEntry(id, storageUpdate.string1, storageUpdate.string2)
, password));
break;
@ -742,7 +790,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal3String
);
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Groups.UpdateGroupPublicStorageEntry(id, storageUpdate.string1, storageUpdate.string2, storageUpdate.string3)
, password));
break;
@ -754,7 +802,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal3String
);
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Groups.UpdateGroupPrivateStorageEntry(id, storageUpdate.string1, storageUpdate.string2, storageUpdate.string3)
, password));
break;
@ -766,43 +814,43 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal2String
);
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Groups.GetGroupPrivateStorageEntry(id, storageUpdate.string1, storageUpdate.string2)
, password));
break;
}
case "user/dm/list":
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Utils.GetUserDms(id)
, password));
break;
case "user/group/list":
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Groups.GetUserGroups(id)
, password));
break;
case "user/dm/invite":
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Requests.DmInvite(id, serializedBody.string2)
, password));
break;
case "user/dm/invite/revoke":
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Requests.DmInviteRevoke(id, serializedBody.string2)
, password));
break;
case "user/dm/invite/decline":
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Requests.DmInviteDecline(id, serializedBody.string2)
, password));
break;
case "user/dm/create":
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Requests.DmCreate(id, serializedBody.string2), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Requests.DmCreate(id, serializedBody.string2), password));
break;
case "group/create":
{
@ -810,11 +858,11 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
serializedBody.string2,
AppJsonSerializerContext.Default.Universal3String
);
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupCreate(id, groupCreate.string1, groupCreate.string2, groupCreate.string3), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupCreate(id, groupCreate.string1, groupCreate.string2, groupCreate.string3), password));
break;
}
case "group/info":
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupInfo(serializedBody.string2), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupInfo(serializedBody.string2), password));
break;
case "group/settings/update":
{
@ -822,7 +870,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
serializedBody.string2,
AppJsonSerializerContext.Default.Universal3String
);
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupSettingsUpdate(id, req.string1, req.string2, req.string3), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupSettingsUpdate(id, req.string1, req.string2, req.string3), password));
break;
}
case "group/invite":
@ -831,7 +879,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
serializedBody.string2,
AppJsonSerializerContext.Default.Universal3String
);
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupInvite(id, payload.string1, payload.string2, payload.string3), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupInvite(id, payload.string1, payload.string2, payload.string3), password));
break;
}
case "group/invite/revoke":
@ -840,7 +888,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
serializedBody.string2,
AppJsonSerializerContext.Default.Universal2String
);
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupInviteRevoke(id, payload.string1, payload.string2), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupInviteRevoke(id, payload.string1, payload.string2), password));
break;
}
case "group/invite/decline":
@ -849,7 +897,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
serializedBody.string2,
AppJsonSerializerContext.Default.Universal2String
);
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupInviteDecline(id, payload.string1, payload.string2), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupInviteDecline(id, payload.string1, payload.string2), password));
break;
}
case "group/join":
@ -858,7 +906,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
serializedBody.string2,
AppJsonSerializerContext.Default.Universal2String
);
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupJoin(id, payload.string1, payload.string2), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupJoin(id, payload.string1, payload.string2), password));
break;
}
case "group/leave":
@ -867,7 +915,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
serializedBody.string2,
AppJsonSerializerContext.Default.Universal2String
);
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupLeave(id, payload.string1), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupLeave(id, payload.string1), password));
break;
}
case "group/kick":
@ -876,7 +924,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
serializedBody.string2,
AppJsonSerializerContext.Default.Universal2String
);
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupKickMember(id, payload.string1, payload.string2), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupKickMember(id, payload.string1, payload.string2), password));
break;
}
case "group/transferowner":
@ -885,7 +933,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
serializedBody.string2,
AppJsonSerializerContext.Default.Universal2String
);
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupTransferOwner(id, payload.string1, payload.string2), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupTransferOwner(id, payload.string1, payload.string2), password));
break;
}
case "room/role":
@ -894,14 +942,14 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
serializedBody.string2,
AppJsonSerializerContext.Default.Universal3String
);
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Shared.GetUserRole(id, payload.string1, payload.string2, payload.string3), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Shared.GetUserRole(id, payload.string1, payload.string2, payload.string3), password));
break;
}
case "group/members":
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupMembers(id, serializedBody.string2), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupMembers(id, serializedBody.string2), password));
break;
case "group/key/rotate":
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupKeyRotate(id, serializedBody.string2), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupKeyRotate(id, serializedBody.string2), password));
break;
case "group/key/get":
{
@ -909,7 +957,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
serializedBody.string2,
AppJsonSerializerContext.Default.Universal2String
);
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupKeyGet(id, payload.string1, payload.string2), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Groups.GroupKeyGet(id, payload.string1, payload.string2), password));
break;
}
case "dm/message/send":
@ -919,7 +967,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal4String
);
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Shared.MessageSend(id, "dms", msgSend.string1, msgSend.string2, msgSend.string3, msgSend.string4)
, password));
break;
@ -931,7 +979,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal2String
);
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Shared.MessageDelete(id, "dms", msgDel.string1, msgDel.string2)
, password));
break;
@ -943,7 +991,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal2String
);
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Shared.MessagePinToggle(id, "dms", msgPin.string1, msgPin.string2)
, password));
break;
@ -955,7 +1003,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal3String
);
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Shared.MessageReactAdd(id, "dms", msgReact.string1, msgReact.string2, msgReact.string3)
, password));
break;
@ -967,7 +1015,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal3String
);
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Shared.MessageReactRemove(id, "dms", msgReactRm.string1, msgReactRm.string2, msgReactRm.string3)
, password));
break;
@ -978,12 +1026,12 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
serializedBody.string2,
AppJsonSerializerContext.Default.Universal3String
);
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Shared.GetMessages(id, "dms", msgGet.string1, msgGet.string3, msgGet.string2), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Shared.GetMessages(id, "dms", msgGet.string1, msgGet.string3, msgGet.string2), password));
break;
}
case "dm/messages/pinned":
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Shared.MessageGetPinned(id, "dms", serializedBody.string2)
, password));
break;
@ -993,12 +1041,12 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
serializedBody.string2,
AppJsonSerializerContext.Default.Universal3String
);
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Shared.GetMessages(id, "groups", msgGet.string1, msgGet.string3, msgGet.string2), password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(await Room.Shared.GetMessages(id, "groups", msgGet.string1, msgGet.string3, msgGet.string2), password));
break;
}
case "group/messages/pinned":
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Shared.MessageGetPinned(id, "groups", serializedBody.string2)
, password));
break;
@ -1009,7 +1057,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal5String
);
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Shared.MessageSend(id, "groups", msgSend.string1, msgSend.string2, msgSend.string3, msgSend.string4, msgSend.string5)
, password));
break;
@ -1021,7 +1069,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal2String
);
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Shared.MessageDelete(id, "groups", msgDel.string1, msgDel.string2)
, password));
break;
@ -1033,26 +1081,26 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal3String
);
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Shared.MessageReactAdd(id, "groups", msgReact.string1, msgReact.string2, msgReact.string3)
, password));
break;
}
case "room/voice/join":
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Shared.VoiceJoin(id, serializedBody.string2)
, password));
break;
case "room/voice/leave":
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Shared.VoiceLeave(id, serializedBody.string2)
, password));
break;
case "room/signal":
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Shared.RoomSignal(id, serializedBody.string2)
, password));
break;
@ -1063,7 +1111,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal3String
);
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Shared.MessageReactRemove(id, "groups", msgReact.string1, msgReact.string2, msgReact.string3)
, password));
break;
@ -1075,7 +1123,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal2String
);
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Shared.MessagePinToggle(id, "groups", msgPin.string1, msgPin.string2)
, password));
break;
@ -1087,7 +1135,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal2String
);
await Account.Utils.UpdateUserDm(id, readGet.string1, "true", "");
await context.Response.WriteAsync(Encryption.Encryption.EncryptString("success:dm.read", password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("success:dm.read", password));
break;
}
case "group/read":
@ -1097,12 +1145,12 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal2String
);
await Room.Groups.UpdateUserGroup(id, readGet.string1, "true", "");
await context.Response.WriteAsync(Encryption.Encryption.EncryptString("success:group.read", password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("success:group.read", password));
break;
}
case "dm/key/get":
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Requests.GetDmKey(id, serializedBody.string2)
, password));
break;
@ -1113,13 +1161,13 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
AppJsonSerializerContext.Default.Universal2String
);
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Room.Requests.UpdateDmKey(id, dmKeyUpdate.string1, dmKeyUpdate.string2)
, password));
break;
}
default:
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(
$"error:unknown.request:{serializedBody.string1}"
, password));
break;
@ -1152,13 +1200,13 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
if (auth != Utils.LOGIN_SUCCESS)
{
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(auth, secretStr));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(auth, secretStr));
return;
}
if (IsActionRateLimited(id))
{
await context.Response.WriteAsync(Encryption.Encryption.EncryptString("error:rate.limit", password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("error:rate.limit", password));
return;
}
@ -1171,7 +1219,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
{
if (ex.Message.StartsWith("error:"))
{
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(ex.Message, password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(ex.Message, password));
return;
}
throw;
@ -1180,7 +1228,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
if (fileBytes == null)
{
await context.Response.WriteAsync(Encryption.Encryption.EncryptString("error:invalid.nonce", password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("error:invalid.nonce", password));
return;
}
@ -1189,7 +1237,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
try
{
await context.Response.WriteAsync(
Encryption.Encryption.EncryptString(
Encryption.Encryption.EncryptForClient(
await Utils.UpdateUserPublicStorageEntryRaw(id, target, fileBytes)
, password));
}
@ -1210,13 +1258,13 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
if (auth != Utils.LOGIN_SUCCESS)
{
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(auth, secretStr));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(auth, secretStr));
return;
}
if (IsActionRateLimited(id))
{
await context.Response.WriteAsync(Encryption.Encryption.EncryptString("error:rate.limit", password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("error:rate.limit", password));
return;
}
@ -1229,14 +1277,14 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
{
if (ex.Message.StartsWith("error:"))
{
await context.Response.WriteAsync(Encryption.Encryption.EncryptString(ex.Message, password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(ex.Message, password));
return;
}
throw;
}
if (fileBytes == null || fileBytes.Length == 0)
{
await context.Response.WriteAsync(Encryption.Encryption.EncryptString("error:empty.body", password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("error:empty.body", password));
return;
}
@ -1256,7 +1304,7 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
}
});
await context.Response.WriteAsync(Encryption.Encryption.EncryptString($"success:{stagingId}", password));
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient($"success:{stagingId}", password));
}
public static async Task GetDmAttachment(HttpContext context, Func<Task> next, IQueryCollection query)
@ -1281,6 +1329,13 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
// Check if user is in room
if (!await Room.Shared.IsMemberOfRoom(id, "dms", dmId)) return;
//walidacja komponentow sciezki - blokuje traversal do dowolnych plikow
if (!Utils.IsSafePathComponent(msgId) || !Utils.IsSafePathComponent(attId))
{
context.Response.StatusCode = 404;
return;
}
string filePath = $"{Room.Shared.GetRoomPath("dms", dmId)}/attachments/{msgId}/{attId}";
if (!Fs.Exists(filePath))
{
@ -1314,6 +1369,13 @@ public static async Task Auth(HttpContext context, Func<Task> next, IQueryCollec
if (!await Room.Shared.IsMemberOfRoom(id, "groups", dmId)) return;
//walidacja komponentow sciezki - blokuje traversal do dowolnych plikow
if (!Utils.IsSafePathComponent(msgId) || !Utils.IsSafePathComponent(attId))
{
context.Response.StatusCode = 404;
return;
}
string filePath = $"{Room.Shared.GetRoomPath("groups", dmId)}/attachments/{msgId}/{attId}";
if (!Fs.Exists(filePath))
{
@ -1333,6 +1395,7 @@ public class CreateHolder
public string name;
public string pass;
public string captcha;
public string KdfSalt;
public X25519PrivateKeyParameters privX25519Server;
public MLKemPrivateKeyParameters privMlKemServer;
}

View file

@ -45,6 +45,21 @@ public class Utils
return ulong.TryParse(idPart, out _) ? idPart : "0";
}
//Pojedynczy komponent sciezki (nazwa pliku/katalogu, id pokoju, klucz itp.)
//Blokuje traversal ("..", "/", "\\") oraz puste/nadmiernie dlugie wartosci.
public static bool IsSafePathComponent(string value, int maxLength = 128)
{
if (string.IsNullOrEmpty(value) || value.Length > maxLength)
{
return false;
}
if (value.Contains("..") || value.Contains('/') || value.Contains('\\'))
{
return false;
}
return true;
}
public static string GetDmId(string id1, string domain1, string id2, string domain2)
{
string u1 = $"{id1};{domain1}";
@ -191,7 +206,7 @@ public class Utils
}
}
return Encryption.Encryption.DecryptByte(body, nonce.Item1 + password);
return Encryption.Encryption.DecryptBytesAuto(body, nonce.Item1 + password);
}
public static async Task UpdateLastLogin(string id)
@ -498,6 +513,24 @@ public class Utils
await Fs.WriteFile(dmPath, Encoding.UTF8.GetBytes(JsonSerializer.Serialize(fileDm, AppJsonSerializerContext.Default.Universal3String)));
}
//Usuwa nieaktualny wpis DM z listy użytkownika - wywoływane gdy serwer wykryje
//brak permisji przy próbie dostępu (użytkownik nie jest uczestnikiem rozmowy).
public static void RemoveStaleUserDmEntry(string userId, string dmId)
{
try
{
if (string.IsNullOrEmpty(dmId) || dmId.Contains("..") || dmId.Contains('/') || dmId.Contains('\\')) return;
string entryPath = $"{ACCOUNTS_DATA_DIR}/{userId}/dms/{dmId}";
if (Fs.Exists(entryPath))
{
Fs.DeleteFile(entryPath);
}
}
catch
{
}
}
public static async Task<string> GetUserDms(string id)
{
string path = $"{ACCOUNTS_DATA_DIR}/{id}/dms";
@ -554,6 +587,11 @@ public class Utils
string lowerNewName = newName.ToLowerInvariant();
string lowerOldName = oldName.ToLowerInvariant();
//createLock -> userLock (ta sama kolejność co Create/finish i BackgroundDelete).
//Zamyka TOCTOU z rejestracją: nazwa rezerwowana pod wspólnym lockiem.
await Requests.createLock.WaitAsync();
try
{
if (lowerNewName != lowerOldName)
{
if (Fs.Exists($"{ACCOUNTS_NAME_DIR}/{lowerNewName}"))
@ -564,6 +602,10 @@ public class Utils
Fs.MoveFile($"{ACCOUNTS_NAME_DIR}/{lowerOldName}", $"{ACCOUNTS_NAME_DIR}/{lowerNewName}");
}
SemaphoreSlim userLock = GetUserLock(id);
await userLock.WaitAsync();
try
{
try
{
await Fs.WriteFile($"{ACCOUNTS_DATA_DIR}/{id}/username", Encoding.UTF8.GetBytes(newName));
@ -576,6 +618,16 @@ public class Utils
}
throw;
}
}
finally
{
userLock.Release();
}
}
finally
{
Requests.createLock.Release();
}
return "success:username.changed";
}

View file

@ -11,6 +11,117 @@ namespace LarpixServer.Encryption;
public class Encryption
{
public const string GcmPrefix = "G1:";
// Odpowiedź do klienta - zawsze AES-GCM (format "G1:" + b64(iv||ct||tag)).
public static string EncryptForClient(string plainText, string passphrase)
{
return EncryptStringGcm(plainText, passphrase);
}
private static byte[] PassphraseToKey(string passphrase)
{
return SHA256.HashData(Encoding.UTF8.GetBytes(passphrase));
}
public static bool IsGcmPacket(string text)
{
return text != null && text.StartsWith(GcmPrefix, StringComparison.Ordinal);
}
public static string EncryptStringGcm(string plainText, string passphrase)
{
byte[] key = PassphraseToKey(passphrase);
byte[] iv = RandomNumberGenerator.GetBytes(12);
byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
byte[] cipherBytes = new byte[plainBytes.Length];
byte[] tag = new byte[16];
using (AesGcm gcm = new AesGcm(key, 16))
{
gcm.Encrypt(iv, plainBytes, cipherBytes, tag);
}
byte[] combined = new byte[iv.Length + cipherBytes.Length + tag.Length];
Buffer.BlockCopy(iv, 0, combined, 0, iv.Length);
Buffer.BlockCopy(cipherBytes, 0, combined, iv.Length, cipherBytes.Length);
Buffer.BlockCopy(tag, 0, combined, iv.Length + cipherBytes.Length, tag.Length);
return GcmPrefix + Convert.ToBase64String(combined);
}
public static string DecryptStringGcm(string gcmCipherText, string passphrase)
{
try
{
byte[] combined = Convert.FromBase64String(gcmCipherText.Substring(GcmPrefix.Length));
byte[] key = PassphraseToKey(passphrase);
byte[] iv = new byte[12];
byte[] tag = new byte[16];
byte[] cipherBytes = new byte[combined.Length - iv.Length - tag.Length];
Buffer.BlockCopy(combined, 0, iv, 0, iv.Length);
Buffer.BlockCopy(combined, iv.Length, cipherBytes, 0, cipherBytes.Length);
Buffer.BlockCopy(combined, iv.Length + cipherBytes.Length, tag, 0, tag.Length);
byte[] plainBytes = new byte[cipherBytes.Length];
using (AesGcm gcm = new AesGcm(key, 16))
{
gcm.Decrypt(iv, cipherBytes, tag, plainBytes);
}
return Encoding.UTF8.GetString(plainBytes);
}
catch (Exception)
{
return "";
}
}
// Binaria przychodzące: wyłącznie "G1:" + iv||ct||tag. Bez prefiksu = odrzucamy.
public static byte[] DecryptBytesAuto(byte[] combinedBytes, string passphrase)
{
try
{
if (combinedBytes == null || combinedBytes.Length < 3)
{
return new byte[] { };
}
if (combinedBytes[0] != 'G' || combinedBytes[1] != '1' || combinedBytes[2] != ':')
{
return new byte[] { };
}
byte[] key = PassphraseToKey(passphrase);
int bodyOffset = Encoding.ASCII.GetByteCount(GcmPrefix);
byte[] iv = new byte[12];
byte[] tag = new byte[16];
byte[] cipherBytes = new byte[combinedBytes.Length - bodyOffset - iv.Length - tag.Length];
if (cipherBytes.Length < 0)
{
return new byte[] { };
}
Buffer.BlockCopy(combinedBytes, bodyOffset, iv, 0, iv.Length);
Buffer.BlockCopy(combinedBytes, bodyOffset + iv.Length, cipherBytes, 0, cipherBytes.Length);
Buffer.BlockCopy(combinedBytes, bodyOffset + iv.Length + cipherBytes.Length, tag, 0, tag.Length);
byte[] plainBytes = new byte[cipherBytes.Length];
using (AesGcm gcm = new AesGcm(key, 16))
{
gcm.Decrypt(iv, cipherBytes, tag, plainBytes);
}
return plainBytes;
}
catch (Exception)
{
return new byte[] { };
}
}
public static (byte[] pubX25519, X25519PrivateKeyParameters privX25519, byte[] pubMlKem, MLKemPrivateKeyParameters privMlKem) InitHybridKEM()
{
var random = new SecureRandom();
@ -188,14 +299,15 @@ public class Encryption
public static string PacketDecPass(string encResult, string key, string nonce)
{
return DecryptString(encResult, nonce + key);
//tylko GCM - pakiety bez prefiksu "G1:" odrzucane
return IsGcmPacket(encResult) ? DecryptStringGcm(encResult, nonce + key) : "";
}
public static string GetRandomString(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz!@_";
return new string(Enumerable.Repeat(chars, length)
.Select(s => s[Random.Shared.Next(s.Length)]).ToArray());
.Select(s => s[RandomNumberGenerator.GetInt32(s.Length)]).ToArray());
}
public static string ComputeSHA3_512(string input)

View file

@ -143,18 +143,26 @@ public class Fs
}
var fileInfo = new FileInfo(path);
if (fileInfo.Length < CACHE_ENTRY_SIZE)
if (!fileInfo.Exists)
{
//samonaprawa cache: wpis existCache może być nieaktualny
existCache[path] = false;
return Array.Empty<byte>();
}
byte[] content = await File.ReadAllBytesAsync(path);
fileCache[path] = new FileData(content, false, fileInfo.Length);
if (content.Length < CACHE_ENTRY_SIZE)
{
fileCache[path] = new FileData(content, false, content.Length);
}
existCache[path] = true;
return content;
}
byte[] bytes = await File.ReadAllBytesAsync(path);
existCache[path] = true;
return bytes;
catch (FileNotFoundException)
{
existCache[path] = false;
fileCache.TryRemove(path, out _);
return Array.Empty<byte>();
}
finally
{

View file

@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using LarpixServer.Account;
@ -33,6 +34,12 @@ public class Program
{
if (entry.Value.resetAt <= nowSec) VoiceAuthLimits.TryRemove(entry.Key, out _);
}
//prune pozostałych rate limitów (okna 60s)
PruneRateLimits(AuthRateLimits, nowSec);
PruneRateLimits(NonceRateLimits, nowSec);
PruneRateLimits(KdfSaltRateLimits, nowSec);
PruneRateLimits(AccountInitRateLimits, nowSec);
LarpixServer.Account.Requests.PruneActionRateLimits(nowSec);
}
});
@ -44,7 +51,7 @@ public class Program
{
serverOptions.AddServerHeader = false;
serverOptions.ListenAnyIP(PORT);
serverOptions.Limits.MaxRequestBodySize = null;
serverOptions.Limits.MaxRequestBodySize = MAX_BODY_SIZE;
});
var app = builder.Build();
@ -98,6 +105,7 @@ public class Program
context.Response.Headers["Access-Control-Allow-Origin"] = "*";
context.Response.Headers["Access-Control-Allow-Headers"] = "*";
context.Response.Headers["Access-Control-Allow-Methods"] = "*";
if (context.Request.Method.ToUpper() == "OPTIONS")
{
context.Response.StatusCode = 200;
@ -252,9 +260,9 @@ public class Program
case "/_larpix/group/attachment/get":
await Account.Requests.GetGroupAttachment(context, next, query);
return;
case "/_larpix/federation":
await Federation.Receiver.FederationRequest(context, next, query, reader);
return;
//case "/_larpix/federation": //federation wylaczona (brak uwierzytelnienia miedzyserwerowego)
// await Federation.Receiver.FederationRequest(context, next, query, reader);
// return;
case "/_larpix/user/key/getpublic":
await Account.Requests.GetUserPublicKey(context, next, query, reader);
return;
@ -264,6 +272,9 @@ public class Program
case "/_larpix/group/storage/public/getentry":
await Account.Requests.GetGroupPublicStorageEntry(context, next, query, reader);
return;
case "/_larpix/kdfsalt":
await Account.Requests.GetKdfSalt(context, query);
return;
}
}
catch (Exception ex)
@ -280,4 +291,12 @@ public class Program
Console.WriteLine("Starting server at port: " + PORT);
app.Run();
}
private static void PruneRateLimits(ConcurrentDictionary<string, (int count, long resetAt)> store, long nowSec)
{
foreach (var entry in store)
{
if (entry.Value.resetAt <= nowSec) store.TryRemove(entry.Key, out _);
}
}
}

View file

@ -9,6 +9,19 @@ namespace LarpixServer.Room;
public class Groups
{
//groupId składa się z części ścieżek (id;domain) - blokuj traversal
public static bool IsSafeGroupId(string groupId)
{
if (string.IsNullOrEmpty(groupId) || groupId.Length > 128) return false;
string[] parts = groupId.Split(';');
foreach (string part in parts)
{
if (string.IsNullOrEmpty(part)) return false;
if (part.Contains("..") || part.Contains('/') || part.Contains('\\')) return false;
}
return true;
}
public static async Task UpdateUserGroup(string id, string groupId, string isRead = "false", string timestamp = "")
{
if (await Account.Utils.NameFromId(id) == "") //if user account just got deleted
@ -53,10 +66,34 @@ public class Groups
await Fs.WriteFile(groupPath, Encoding.UTF8.GetBytes(JsonSerializer.Serialize(fileGroup, AppJsonSerializerContext.Default.Universal3String)));
}
//Usuwa nieaktualny wpis grupy z listy użytkownika - wywoływane gdy serwer wykryje
//brak permisji przy próbie dostępu (np. po kicku, gdy wpis został na liście).
public static void RemoveStaleUserGroupEntry(string userId, string groupId)
{
try
{
if (!IsSafeGroupId(groupId)) return;
if (!groupId.Contains(';'))
{
groupId = $"{groupId};{Utils.Utils.DOMAIN}";
}
string entryPath = $"{Utils.Utils.ACCOUNTS_DATA_DIR}/{userId}/groups/{groupId}";
if (Fs.Exists(entryPath))
{
Fs.DeleteFile(entryPath);
}
}
catch
{
}
}
//Globalny lock alokacji groupId - zapobiega duplikacji ID przy równoległych tworzeniach.
private static readonly SemaphoreSlim GroupIdLock = new SemaphoreSlim(1, 1);
public static async Task<string> GroupCreate(string id, string name, string body, string historyVisible = "false")
{
SemaphoreSlim roomLock = Room.Requests.GetRoomLock(id);
await roomLock.WaitAsync();
await GroupIdLock.WaitAsync();
try
{
string groupsDir = $"{ROOMS_DIR}/groups";
@ -114,7 +151,7 @@ public class Groups
}
finally
{
roomLock.Release();
GroupIdLock.Release();
}
}
@ -154,6 +191,7 @@ public class Groups
public static async Task<string> GroupSettingsUpdate(string userId, string groupId, string name, string historyVisible)
{
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
string groupPath = GetGroupPath(groupId);
if (!Fs.Exists(groupPath)) return "error:group.not.found";
@ -219,6 +257,7 @@ public class Groups
public static async Task<string> GroupInfo(string groupId)
{
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
string groupPath = GetGroupPath(groupId);
if (!Fs.Exists(groupPath))
{
@ -277,6 +316,7 @@ public class Groups
public static async Task<string> GroupInvite(string id, string groupId, string targetId, string keysPayload = "")
{
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
bool isLocal = Account.Utils.IsUserLocal(targetId, out string domain);
string tId = Account.Utils.GetValidIdOrZero(targetId);
@ -312,6 +352,15 @@ public class Groups
if (keysDict == null) return "error:missing.keys";
//indeksy kluczy trafiają do ścieżek plików przy GroupJoin - tylko cyfry
foreach (var key in keysDict.Keys)
{
if (string.IsNullOrEmpty(key) || !System.Text.RegularExpressions.Regex.IsMatch(key, "^[0-9]{1,10}$"))
{
return "error:invalid.keys";
}
}
string lastKeyFile = $"{groupPath}/keys/last";
if (Fs.Exists(lastKeyFile))
{
@ -379,6 +428,7 @@ public class Groups
public static async Task<string> GroupInviteRevoke(string id, string groupId, string targetId)
{
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
string tId = Account.Utils.GetValidIdOrZero(targetId);
if (tId == "0") return "error:invalid.user";
@ -406,6 +456,7 @@ public class Groups
public static async Task<string> GroupInviteDecline(string id, string groupId, string inviterId)
{
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
string iId = Account.Utils.GetValidIdOrZero(inviterId);
if (iId == "0") return "error:invalid.user";
@ -433,6 +484,7 @@ public class Groups
public static async Task<string> GroupJoin(string id, string groupId, string inviterId)
{
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
string iId = Account.Utils.GetValidIdOrZero(inviterId);
if (iId == "0") return "error:invalid.user";
@ -491,10 +543,19 @@ public class Groups
if (keysDict != null)
{
//obrona w głębi: GroupInvite już waliduje, ale tu dane trafiają do ścieżek
foreach (var key in keysDict.Keys)
{
if (string.IsNullOrEmpty(key) || !System.Text.RegularExpressions.Regex.IsMatch(key, "^[0-9]{1,10}$"))
{
return "error:invalid.keys";
}
}
foreach (var kvp in keysDict)
{
string keyIndex = kvp.Key;
string keyData = kvp.Value;
string keyData = kvp.Value ?? "";
string newKeyDir = $"{groupPath}/keys/{keyIndex}";
Fs.CreateDirectory(newKeyDir);
await Fs.WriteFile($"{newKeyDir}/{id};{DOMAIN}", Encoding.UTF8.GetBytes(keyData));
@ -555,6 +616,7 @@ public class Groups
public static async Task<string> GroupMembers(string id, string groupId)
{
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
string groupPath = GetGroupPath(groupId);
if (!Fs.Exists(groupPath)) return "error:group.not.found";
@ -580,6 +642,16 @@ public class Groups
return "error:invalid.payload";
string groupId = payload.groupId;
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
//klucze rotacji trafiają do ścieżek - członkowie (nazwy plików) i wartości muszą być bezpieczne
foreach (var kvp in payload.keys)
{
if (!Account.Utils.IsSafePathComponent(kvp.Key) || kvp.Value == null ||
kvp.Value.Length > LarpixServer.Room.Requests.MAX_DM_KEY_SIZE)
{
return "error:invalid.payload";
}
}
string groupPath = GetGroupPath(groupId);
if (!Fs.Exists(groupPath)) return "error:group.not.found";
@ -648,6 +720,10 @@ public class Groups
public static async Task<string> GroupKeyGet(string id, string groupId, string keyIndex)
{
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
//keyIndex trafia do sciezki - tylko cyfry
if (string.IsNullOrEmpty(keyIndex) || !System.Text.RegularExpressions.Regex.IsMatch(keyIndex, "^[0-9]{1,10}$")) return "";
string groupPath = GetGroupPath(groupId);
if (!Fs.Exists(groupPath)) return "error:group.not.found";
@ -663,6 +739,7 @@ public class Groups
public static async Task<byte[]> GetGroupPublicStorageEntry(string groupId, string entry)
{
if (!IsSafeGroupId(groupId)) return new byte[] {};
if (string.IsNullOrEmpty(entry) || entry.Contains("..") || entry.Contains("/") || entry.Contains("\\")) return new byte[] {};
string[] parts = groupId.Split(';');
string id = parts[0];
@ -673,6 +750,7 @@ public class Groups
}
public static async Task<string> UpdateGroupPublicStorageEntry(string userId, string groupId, string entry, string contentBase64)
{
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
if (string.IsNullOrEmpty(entry) || entry.Contains("..") || entry.Contains("/") || entry.Contains("\\")) return "error:invalid.entry.name";
string[] parts = groupId.Split(';');
string id = parts[0];
@ -707,6 +785,7 @@ public class Groups
}
public static async Task<string> GetGroupPrivateStorageEntry(string userId, string groupId, string entry)
{
if (!IsSafeGroupId(groupId)) return "";
if (string.IsNullOrEmpty(entry) || entry.Contains("..") || entry.Contains("/") || entry.Contains("\\")) return "";
string[] parts = groupId.Split(';');
string id = parts[0];
@ -719,6 +798,7 @@ public class Groups
}
public static async Task<string> UpdateGroupPrivateStorageEntry(string userId, string groupId, string entry, string contentBase64)
{
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
if (string.IsNullOrEmpty(entry) || entry.Contains("..") || entry.Contains("/") || entry.Contains("\\")) return "error:invalid.entry.name";
string[] parts = groupId.Split(';');
string id = parts[0];
@ -749,6 +829,7 @@ public class Groups
public static async Task<string> GroupDelete(string groupId)
{
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
string[] parts = groupId.Split(';');
string id = parts[0];
string domain = parts.Length > 1 ? parts[1] : Utils.Utils.DOMAIN;
@ -786,6 +867,7 @@ public class Groups
public static async Task<string> GroupLeave(string userId, string groupId)
{
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
string[] parts = groupId.Split(';');
string id = parts[0];
string domain = parts.Length > 1 ? parts[1] : Utils.Utils.DOMAIN;
@ -892,6 +974,7 @@ public class Groups
public static async Task<string> GroupKickMember(string id, string groupId, string targetId)
{
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
string groupPath = GetGroupPath(groupId);
if (!Fs.Exists(groupPath)) return "error:group.not.found";
@ -916,7 +999,10 @@ public class Groups
Fs.DeleteFile(memberPath);
string userGroupPath = $"{Utils.Utils.ACCOUNTS_DATA_DIR}/{tId}/groups/{groupId}";
//UpdateUserGroup normalizuje wpis do "{id};{DOMAIN}" - tu musi być ta sama forma,
//inaczej przy kicku zostanie osierocony wpis na liście grup użytkownika
string normalizedGroupId = groupId.Contains(';') ? groupId : $"{groupId};{Utils.Utils.DOMAIN}";
string userGroupPath = $"{Utils.Utils.ACCOUNTS_DATA_DIR}/{tId}/groups/{normalizedGroupId}";
if (Fs.Exists(userGroupPath))
{
Fs.DeleteFile(userGroupPath);
@ -978,6 +1064,7 @@ public class Groups
public static async Task<string> GroupTransferOwner(string id, string groupId, string targetId)
{
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
string groupPath = GetGroupPath(groupId);
if (!Fs.Exists(groupPath)) return "error:group.not.found";

View file

@ -58,7 +58,12 @@ public class Requests
}
public static async Task<string> GetDmMessages(string id, string dmId, string startOffset, string countStr)
{
if (!await IsMemberOfDm(id, dmId)) return "error:forbidden";
if (!await IsMemberOfDm(id, dmId))
{
//brak permisji - usuń nieaktualny wpis DM z listy użytkownika (jeśli istnieje)
Account.Utils.RemoveStaleUserDmEntry(id, dmId);
return "error:forbidden";
}
string msgDir = $"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/messages";
if (!Fs.Exists(msgDir)) return "{}";
@ -176,18 +181,17 @@ public class Requests
bool isLocal = Account.Utils.IsUserLocal(targetId, out string domain);
string tId = Account.Utils.GetValidIdOrZero(targetId);
if (!isLocal) //federation wylaczona (brak uwierzytelnienia miedzyserwerowego)
{
return "error:federation.disabled";
}
string checkDmId = Account.Utils.GetDmId(id, DOMAIN, tId, domain);
if (Fs.Exists($"{ROOMS_DIR}/dms/{DOMAIN}/{checkDmId}"))
{
return "error:dm.already.exists";
}
if (!isLocal) //federation
{
await Fs.WriteFile(ACCOUNTS_DATA_DIR + $"/{id}/dminvites/sent/{tId};{domain}", Encoding.UTF8.GetBytes(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString()));
return await Federation.Sender.DmInvite(id, domain, tId);
}
string id2 = tId;
if (id2 == "0" || string.IsNullOrEmpty(id2))
{
@ -229,10 +233,9 @@ public class Requests
bool isLocal = Account.Utils.IsUserLocal(targetId, out string domain);
string tId = Account.Utils.GetValidIdOrZero(targetId);
if (!isLocal) //federation
if (!isLocal) //federation wylaczona
{
Fs.DeleteFile(ACCOUNTS_DATA_DIR + $"/{id}/dminvites/sent/{tId};{domain}");
return await Federation.Sender.DmInviteRevoke(id, domain, tId);
return "error:federation.disabled";
}
string id2 = tId;
@ -262,10 +265,9 @@ public class Requests
bool isLocal = Account.Utils.IsUserLocal(targetId, out string domain);
string tId = Account.Utils.GetValidIdOrZero(targetId);
if (!isLocal) //federation
if (!isLocal) //federation wylaczona
{
Fs.DeleteFile(ACCOUNTS_DATA_DIR + $"/{id}/dminvites/recv/{tId};{domain}");
return await Federation.Sender.DmInviteDecline(id, domain, tId);
return "error:federation.disabled";
}
string id2 = tId;
@ -291,9 +293,6 @@ public class Requests
}
public static async Task<string> DmCreate(string id, string body)
{
await createLock.WaitAsync();
try
{
Universal3String serializedBody = JsonSerializer.Deserialize( //1 is targetId, 2 is creators dm key, 3 is key for ID2
body,
@ -304,11 +303,23 @@ public class Requests
bool isUserLocal = Account.Utils.IsUserLocal(targetId, out string domain);
string id2 = Account.Utils.GetValidIdOrZero(targetId);
if (!isUserLocal) //federation wylaczona
{
return "error:federation.disabled";
}
if (id2 == "0" || string.IsNullOrEmpty(id2))
{
return "error:user.not.found";
}
//klucze DM trafiaja na dysk - twardy limit rozmiaru
if (serializedBody.string2 == null || serializedBody.string2.Length > MAX_DM_KEY_SIZE ||
serializedBody.string3 == null || serializedBody.string3.Length > MAX_DM_KEY_SIZE)
{
return "error:dm.key.too.large";
}
string checkDmId = Account.Utils.GetDmId(id, DOMAIN, id2, domain);
if (Fs.Exists($"{ROOMS_DIR}/dms/{DOMAIN}/{checkDmId}"))
{
@ -330,17 +341,28 @@ public class Requests
}
Fs.DeleteFile(inviteSentFile);
}
else //check sent on federated server
{
if (!await Federation.Sender.HasSentDmInvite(id2, domain, id))
{
return "error:cant.create.dm.without.invitation";
}
}
//else //check sent on federated server (federation wylaczona)
//{
// if (!await Federation.Sender.HasSentDmInvite(id2, domain, id))
// {
// return "error:cant.create.dm.without.invitation";
// }
//}
string inviteFile = $"{ACCOUNTS_DATA_DIR}/{id}/dminvites/recv/{id2}" + (isUserLocal ? "" : $";{domain}");
if (Fs.Exists(inviteFile))
{
//lock na docelowy dmId - serializuje tworzenie tego samego pokoju
//bez globalnego blokowania wszystkich DM-ów
SemaphoreSlim roomLock = GetRoomLock(checkDmId);
await roomLock.WaitAsync();
try
{
//re-check pod lockiem - drugi równoległy DmCreate może już utworzyć pokój
if (Fs.Exists($"{ROOMS_DIR}/dms/{DOMAIN}/{checkDmId}"))
{
return "error:dm.already.exists";
}
try
{
@ -400,10 +422,10 @@ public class Requests
}
});
}
else //federacja
{
await Federation.Sender.AddToDm(id, domain, id2);
}
//else //federacja (wylaczona)
//{
// await Federation.Sender.AddToDm(id, domain, id2);
//}
}
catch (Exception e)
@ -413,13 +435,11 @@ public class Requests
}
return "success:dm.accepted";
}
}
finally
{
createLock.Release();
roomLock.Release();
}
}
return "error:cant.create.dm.without.invitation";
}
@ -443,11 +463,19 @@ public class Requests
return $"error:message.too.long:{sizeStr}";
}
//pola załączników trafiają do ścieżek plików - odrzuć niebezpieczne wartości
if (!string.IsNullOrEmpty(attachment) && !Shared.ValidateAttachmentsJson(attachment))
{
return "error:invalid.attachment";
}
string msgDir = $"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/messages";
long nextId = 0;
Message msg = new Message();
await createLock.WaitAsync();
//lock per pokój (spójnie z Shared.MessageSend) zamiast globalnego createLock
SemaphoreSlim roomSendLock = GetRoomLock(dmId);
await roomSendLock.WaitAsync();
try
{
long last = 0;
@ -507,7 +535,7 @@ public class Requests
}
finally
{
createLock.Release();
roomSendLock.Release();
}
string[] members = Fs.ReadDirectory($"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/members");
@ -632,7 +660,8 @@ public class Requests
string reactDir = $"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/reactions/{msgId}";
if (!Fs.Exists(reactDir)) Fs.CreateDirectory(reactDir);
await createLock.WaitAsync();
SemaphoreSlim reactRoomLock = GetRoomLock(dmId);
await reactRoomLock.WaitAsync();
try
{
long last = 0;
@ -647,7 +676,7 @@ public class Requests
}
finally
{
createLock.Release();
reactRoomLock.Release();
}
string[] members = Fs.ReadDirectory($"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/members");
@ -727,7 +756,8 @@ public class Requests
string pinnedFilePath = $"{pinnedDir}/{msgId}";
bool isNowPinned = false;
await createLock.WaitAsync();
SemaphoreSlim pinRoomLock = GetRoomLock(dmId);
await pinRoomLock.WaitAsync();
try
{
if (Fs.Exists(pinnedFilePath))
@ -767,7 +797,7 @@ public class Requests
}
finally
{
createLock.Release();
pinRoomLock.Release();
}
string[] members = Fs.ReadDirectory($"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/members");

View file

@ -32,6 +32,41 @@ public class Shared
return true;
}
//Pola JSON-a załącznika (staging_id / id) trafiają do ścieżek plików - muszą być bezpieczne.
public static bool IsSafeAttachmentField(string value)
{
return Account.Utils.IsSafePathComponent(value, 128);
}
//Waliduje tablicę załączników: true = OK, false = porzuć wiadomość.
public static bool ValidateAttachmentsJson(string attachmentJson)
{
if (string.IsNullOrEmpty(attachmentJson) || attachmentJson == "[]") return true;
try
{
var node = System.Text.Json.Nodes.JsonNode.Parse(attachmentJson);
if (node is not System.Text.Json.Nodes.JsonArray arr) return true;
foreach (var item in arr)
{
if (item is not System.Text.Json.Nodes.JsonObject obj) continue;
foreach (var fieldName in new[] { "staging_id", "id" })
{
if (obj.ContainsKey(fieldName) &&
!IsSafeAttachmentField(obj[fieldName]?.ToString()))
{
return false;
}
}
}
return true;
}
catch
{
return true; //parsowanie nie powiedzie się później i tak przy zapisie treści
}
}
public static async Task<bool> IsMemberOfRoom(string id, string roomType, string roomId)
{
if (!IsSafeId(roomId)) return false;
@ -83,7 +118,13 @@ public class Shared
public static async Task<string> GetMessages(string id, string roomType, string roomId, string startOffset, string countStr)
{
if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden";
if (!await IsMemberOfRoom(id, roomType, roomId))
{
//brak permisji - jeśli użytkownik ma ten pokój na liście (stan po kicku/usunięciu),
//usuń nieaktualny wpis, żeby lista sama się oczyściła
if (roomType == "groups") Groups.RemoveStaleUserGroupEntry(id, roomId);
return "error:forbidden";
}
string msgDir = $"{GetRoomPath(roomType, roomId)}/messages";
if (!Fs.Exists(msgDir)) return "{}";
@ -204,6 +245,12 @@ public class Shared
return $"error:message.too.long:{sizeStr}";
}
//pola załączników trafiają do ścieżek plików - odrzuć niebezpieczne wartości
if (!string.IsNullOrEmpty(attachment) && !ValidateAttachmentsJson(attachment))
{
return "error:invalid.attachment";
}
long last = 0;
if (Fs.Exists($"{msgDir}/last"))
{
@ -601,6 +648,13 @@ public class Shared
return "room";
}
//Współdzielony klient HTTP do odpytywania serwerów głosowych o load
//(new HttpClient() per żądanie wyczerpałoby gniazda)
private static readonly System.Net.Http.HttpClient VoiceLoadHttp = new System.Net.Http.HttpClient()
{
Timeout = TimeSpan.FromSeconds(2)
};
public static async Task<string> VoiceJoin(string id, string body)
{
Universal2String serializedBody = JsonSerializer.Deserialize(
@ -631,10 +685,8 @@ public class Shared
{
try
{
using var client = new System.Net.Http.HttpClient();
client.Timeout = TimeSpan.FromSeconds(2);
string url = ip.StartsWith("http") ? ip : $"http://{ip}";
string loadStr = await client.GetStringAsync($"{url}/load");
string loadStr = await VoiceLoadHttp.GetStringAsync($"{url}/load");
if (long.TryParse(loadStr, out long load))
{
if (load < bestLoad)

View file

@ -60,6 +60,7 @@ public class KeyExchangePayload
public string pubX25519 { get; set; }
public string pubMlKem { get; set; }
public string idKey { get; set; }
public string kdfSalt { get; set; }
}
public class KeyExchangePayloadClient

View file

@ -30,6 +30,28 @@ public class Utils
// Fixed 60s window: (request count, window reset unix time).
public static ConcurrentDictionary<string, (int count, long resetAt)> VoiceAuthLimits = new();
// Rate limity per-minute (okno 60s). Wszystkie konfigurowalne przez .env.
public static ConcurrentDictionary<string, (int count, long resetAt)> AuthRateLimits = new();
public static ConcurrentDictionary<string, (int count, long resetAt)> NonceRateLimits = new();
public static ConcurrentDictionary<string, (int count, long resetAt)> KdfSaltRateLimits = new();
public static ConcurrentDictionary<string, (int count, long resetAt)> 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
// Zwraca true jeśli przekroczono limit. Okno stałe 60s.
public static bool RateLimited(ConcurrentDictionary<string, (int count, long resetAt)> store, string key, int limit)
{
if (limit <= 0) return false; // <=0 wyłącza limit
long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var window = store.AddOrUpdate(key,
(1, nowSec + 60),
(_, old) => old.resetAt <= nowSec ? (1, nowSec + 60) : (old.count + 1, old.resetAt));
return window.count > limit;
}
public static string DATA_DIR = "./data";
public static string ROOMS_DIR = "./data/rooms";
public static string ACCOUNTS_DIR = "./data/accounts";
@ -126,14 +148,28 @@ public class Utils
public static async Task WsHandler(HttpContext context, System.Net.WebSockets.WebSocket webSocket, IQueryCollection query)
{
var buffer = new byte[8192];
var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
//odbierz PIERWSZĄ pełną ramkę (moze byc pofragmentowana)
int totalRead = 0;
System.Net.WebSockets.WebSocketReceiveResult result;
while (true)
{
if (totalRead >= buffer.Length)
{
await webSocket.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.MessageTooBig, "Auth too large", CancellationToken.None);
return;
}
result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer, totalRead, buffer.Length - totalRead), CancellationToken.None);
totalRead += result.Count;
if (result.MessageType == System.Net.WebSockets.WebSocketMessageType.Close)
{
await webSocket.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
return;
}
if (result.EndOfMessage) break;
}
string authMessage = Encoding.UTF8.GetString(buffer, 0, result.Count);
string authMessage = Encoding.UTF8.GetString(buffer, 0, totalRead);
Universal2String authParams;
try {
authParams = JsonSerializer.Deserialize<Universal2String>(authMessage, AppJsonSerializerContext.Default.Universal2String);
@ -143,6 +179,15 @@ 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);
@ -181,6 +226,7 @@ public class Utils
list.Remove(webSocket);
}
}
WsSendLocks.TryRemove(webSocket, out _);
if (webSocket.State == System.Net.WebSockets.WebSocketState.Open)
{
await webSocket.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
@ -221,29 +267,66 @@ public class Utils
}
}
//Jeden outstanding SendAsync na WebSocket - semafory per-socket serializują wysyłkę
//(równoległe SendAsync na tym samym sockecie rzucają InvalidOperationException).
public static readonly ConcurrentDictionary<System.Net.WebSockets.WebSocket, SemaphoreSlim> WsSendLocks = new();
private static SemaphoreSlim GetWsSendLock(System.Net.WebSockets.WebSocket ws)
{
return WsSendLocks.GetOrAdd(ws, _ => new SemaphoreSlim(1, 1));
}
public static async Task SendWsMessage(string id, string message)
{
if (ActiveWebSockets.TryGetValue(id, out var list))
if (!ActiveWebSockets.TryGetValue(id, out var list))
{
List<System.Net.WebSockets.WebSocket> toRemove = null;
var bytes = Encoding.UTF8.GetBytes(message);
lock(list) {
foreach(var ws in list)
return;
}
byte[] bytes;
System.Net.WebSockets.WebSocket[] snapshot;
lock (list)
{
if (list.Count == 0) return;
bytes = Encoding.UTF8.GetBytes(message);
snapshot = list.ToArray();
}
foreach (var ws in snapshot)
{
bool remove = false;
var sendLock = GetWsSendLock(ws);
await sendLock.WaitAsync();
try
{
if (ws.State == System.Net.WebSockets.WebSocketState.Open)
{
_ = ws.SendAsync(new ArraySegment<byte>(bytes), System.Net.WebSockets.WebSocketMessageType.Text, true, CancellationToken.None);
try
{
await ws.SendAsync(new ArraySegment<byte>(bytes), System.Net.WebSockets.WebSocketMessageType.Text, true, CancellationToken.None);
}
catch
{
remove = true;
}
}
else
{
if (toRemove == null) toRemove = new();
toRemove.Add(ws);
remove = true;
}
}
if (toRemove != null)
finally
{
foreach(var ws in toRemove) list.Remove(ws);
sendLock.Release();
}
if (remove)
{
lock (list)
{
list.Remove(ws);
}
WsSendLocks.TryRemove(ws, out _);
}
}
}
@ -346,6 +429,11 @@ public class Utils
VOICE_AUTH_RATE_LIMIT = parsedVoiceAuthLimit;
}
AUTH_RATE_LIMIT = int.TryParse(Environment.GetEnvironmentVariable("AUTH_RATE_LIMIT"), out int parsedAuthLimit) ? parsedAuthLimit : AUTH_RATE_LIMIT;
NONCE_RATE_LIMIT = int.TryParse(Environment.GetEnvironmentVariable("NONCE_RATE_LIMIT"), out int parsedNonceLimit) ? parsedNonceLimit : NONCE_RATE_LIMIT;
KDFSALT_RATE_LIMIT = int.TryParse(Environment.GetEnvironmentVariable("KDFSALT_RATE_LIMIT"), out int parsedKdfSaltLimit) ? parsedKdfSaltLimit : KDFSALT_RATE_LIMIT;
ACCOUNT_INIT_RATE_LIMIT = int.TryParse(Environment.GetEnvironmentVariable("ACCOUNT_INIT_RATE_LIMIT"), out int parsedAccountInitLimit) ? parsedAccountInitLimit : ACCOUNT_INIT_RATE_LIMIT;
VOICE_IPS.Clear();
port = Environment.GetEnvironmentVariable("VOICE_IPS");