From ade2128a73ba873d3764e655706c4eb2f20ce43b Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sun, 30 Aug 2026 03:53:54 -0400 Subject: [PATCH] Switch Argon2 hashes to PHC string format (#256) --- .../Features.Auth/Services/TokenService.cs | 20 +++--- .../Argon2Infrastructure.cs | 70 ++++++++++++++----- 2 files changed, 64 insertions(+), 26 deletions(-) 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); + } }