diff --git a/web/backend/Features/Features.Auth/Services/TokenService.cs b/web/backend/Features/Features.Auth/Services/TokenService.cs
index e766c415..8ed118a2 100644
--- a/web/backend/Features/Features.Auth/Services/TokenService.cs
+++ b/web/backend/Features/Features.Auth/Services/TokenService.cs
@@ -106,16 +106,16 @@ public class TokenService : ITokenService
return new RefreshTokenResult(user.Id, user.UserName, newRefresh, newAccess);
}
- ///
- /// Performs the shared validation logic for access, refresh, and confirmation tokens:
- /// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
- ///
- /// A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.
- ///
- /// Thrown when required claims are missing, the user ID claim is not a valid ,
- /// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
- ///
- private async Task ValidateTokenInternalAsync(
+ ///
+ /// Performs the shared validation logic for access, refresh, and confirmation tokens:
+ /// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
+ ///
+ /// A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.
+ ///
+ /// Thrown when required claims are missing, the user ID claim is not a valid ,
+ /// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
+ ///
+ private async Task ValidateTokenInternalAsync(
string token,
string secret,
string tokenType
diff --git a/web/backend/Infrastructure/Infrastructure.PasswordHashing/Argon2Infrastructure.cs b/web/backend/Infrastructure/Infrastructure.PasswordHashing/Argon2Infrastructure.cs
index bda6f8ad..b938365e 100644
--- a/web/backend/Infrastructure/Infrastructure.PasswordHashing/Argon2Infrastructure.cs
+++ b/web/backend/Infrastructure/Infrastructure.PasswordHashing/Argon2Infrastructure.cs
@@ -1,3 +1,4 @@
+using System;
using System.Security.Cryptography;
using System.Text;
using Konscious.Security.Cryptography;
@@ -13,26 +14,32 @@ public class Argon2Infrastructure : IPasswordInfrastructure
private const int HashSize = 32; // 256-bit
private const int ArgonIterations = 4;
private const int ArgonMemoryKb = 65536; // 64MB
+ private const int DegreeOfParallelism = 2; // Hardcoded to prevent thread pool exhaustion
- ///
- /// A string of the form "{base64Salt}:{base64Hash}", suitable for storage.
public string Hash(string password)
{
byte[] salt = RandomNumberGenerator.GetBytes(SaltSize);
- Argon2id argon2 = new(Encoding.UTF8.GetBytes(password))
+
+ using Argon2id argon2 = new(Encoding.UTF8.GetBytes(password))
{
Salt = salt,
- DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
+ DegreeOfParallelism = DegreeOfParallelism,
MemorySize = ArgonMemoryKb,
Iterations = ArgonIterations,
};
byte[] hash = argon2.GetBytes(HashSize);
- return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
+
+ // The PHC string standard requires Base64 without the trailing '=' padding characters
+ string b64Salt = Convert.ToBase64String(salt).TrimEnd('=');
+ string b64Hash = Convert.ToBase64String(hash).TrimEnd('=');
+
+ // Argon2 version is currently 19 (0x13 in hex)
+ return $"$argon2id$v=19$m={ArgonMemoryKb},t={ArgonIterations},p={DegreeOfParallelism}${b64Salt}${b64Hash}";
}
///
- /// Uses a fixed-time comparison to mitigate timing attacks.
+ /// Uses a fixed-time comparison to mitigate timing attacks. Supports standard PHC string format.
///
/// false if the password doesn't match, if is malformed, or if any
/// other error occurs during verification.
@@ -41,27 +48,58 @@ public class Argon2Infrastructure : IPasswordInfrastructure
{
try
{
- string[] parts = stored.Split(':', StringSplitOptions.RemoveEmptyEntries);
- if (parts.Length != 2)
+ // Split the PHC format: $argon2id$v=19$m=65536,t=3,p=4$salt$hash
+ string[] parts = stored.Split('$', StringSplitOptions.RemoveEmptyEntries);
+
+ // We expect exactly 5 parts when empty entries (the one before the first $) are removed
+ if (parts.Length != 5 || parts[0] != "argon2id")
return false;
- byte[] salt = Convert.FromBase64String(parts[0]);
- byte[] expected = Convert.FromBase64String(parts[1]);
+ int memory = 0,
+ iterations = 0,
+ parallelism = 0;
+ string[] parameters = parts[2].Split(',');
- Argon2id argon2 = new(Encoding.UTF8.GetBytes(password))
+ foreach (string param in parameters)
+ {
+ if (param.StartsWith("m="))
+ memory = int.Parse(param.Substring(2));
+ else if (param.StartsWith("t="))
+ iterations = int.Parse(param.Substring(2));
+ else if (param.StartsWith("p="))
+ parallelism = int.Parse(param.Substring(2));
+ }
+
+ byte[] salt = DecodeBase64NoPadding(parts[3]);
+ byte[] expectedHash = DecodeBase64NoPadding(parts[4]);
+
+ // Use the parameters embedded in the stored string, so previously hashed
+ // passwords remain verifiable after a parameter change.
+ using Argon2id argon2 = new(Encoding.UTF8.GetBytes(password))
{
Salt = salt,
- DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
- MemorySize = ArgonMemoryKb,
- Iterations = ArgonIterations,
+ DegreeOfParallelism = parallelism,
+ MemorySize = memory,
+ Iterations = iterations,
};
- byte[] actual = argon2.GetBytes(expected.Length);
- return CryptographicOperations.FixedTimeEquals(actual, expected);
+ byte[] actualHash = argon2.GetBytes(expectedHash.Length);
+
+ return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash);
}
catch
{
return false;
}
}
+
+ ///
+ /// .NET's Convert.FromBase64String requires padding. The PHC format drops padding.
+ /// This helper restores the padding before converting.
+ ///
+ private static byte[] DecodeBase64NoPadding(string base64)
+ {
+ string padded = base64.PadRight(base64.Length + (4 - base64.Length % 4) % 4, '=');
+ return Convert.FromBase64String(padded);
+ }
}