Switch Argon2 hashes to PHC string format (#256)

This commit is contained in:
Aaron Po
2026-08-30 03:53:54 -04:00
committed by GitHub
parent e5b354e322
commit ade2128a73
2 changed files with 64 additions and 26 deletions

View File

@@ -106,16 +106,16 @@ public class TokenService : ITokenService
return new RefreshTokenResult(user.Id, user.UserName, newRefresh, newAccess); return new RefreshTokenResult(user.Id, user.UserName, newRefresh, newAccess);
} }
/// <summary> /// <summary>
/// Performs the shared validation logic for access, refresh, and confirmation tokens: /// 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. /// validates the JWT signature/expiration, then extracts and parses the user ID and username claims.
/// </summary> /// </summary>
/// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param> /// <param name="tokenType">A human-readable label (e.g. "access", "refresh", "confirmation") used in error messages.</param>
/// <exception cref="UnauthorizedException"> /// <exception cref="UnauthorizedException">
/// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />, /// Thrown when required claims are missing, the user ID claim is not a valid <see cref="Guid" />,
/// or the underlying token validation fails for any other reason (e.g. expired or invalid signature). /// or the underlying token validation fails for any other reason (e.g. expired or invalid signature).
/// </exception> /// </exception>
private async Task<ValidatedToken> ValidateTokenInternalAsync( private async Task<ValidatedToken> ValidateTokenInternalAsync(
string token, string token,
string secret, string secret,
string tokenType string tokenType

View File

@@ -1,3 +1,4 @@
using System;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Konscious.Security.Cryptography; using Konscious.Security.Cryptography;
@@ -13,26 +14,32 @@ public class Argon2Infrastructure : IPasswordInfrastructure
private const int HashSize = 32; // 256-bit private const int HashSize = 32; // 256-bit
private const int ArgonIterations = 4; private const int ArgonIterations = 4;
private const int ArgonMemoryKb = 65536; // 64MB private const int ArgonMemoryKb = 65536; // 64MB
private const int DegreeOfParallelism = 2; // Hardcoded to prevent thread pool exhaustion
/// <inheritdoc />
/// <returns>A string of the form <c>"{base64Salt}:{base64Hash}"</c>, suitable for storage.</returns>
public string Hash(string password) public string Hash(string password)
{ {
byte[] salt = RandomNumberGenerator.GetBytes(SaltSize); byte[] salt = RandomNumberGenerator.GetBytes(SaltSize);
Argon2id argon2 = new(Encoding.UTF8.GetBytes(password))
using Argon2id argon2 = new(Encoding.UTF8.GetBytes(password))
{ {
Salt = salt, Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), DegreeOfParallelism = DegreeOfParallelism,
MemorySize = ArgonMemoryKb, MemorySize = ArgonMemoryKb,
Iterations = ArgonIterations, Iterations = ArgonIterations,
}; };
byte[] hash = argon2.GetBytes(HashSize); 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}";
} }
/// <inheritdoc /> /// <inheritdoc />
/// <remarks>Uses a fixed-time comparison to mitigate timing attacks.</remarks> /// <remarks>Uses a fixed-time comparison to mitigate timing attacks. Supports standard PHC string format.</remarks>
/// <returns> /// <returns>
/// <c>false</c> if the password doesn't match, if <paramref name="stored" /> is malformed, or if any /// <c>false</c> if the password doesn't match, if <paramref name="stored" /> is malformed, or if any
/// other error occurs during verification. /// other error occurs during verification.
@@ -41,27 +48,58 @@ public class Argon2Infrastructure : IPasswordInfrastructure
{ {
try try
{ {
string[] parts = stored.Split(':', StringSplitOptions.RemoveEmptyEntries); // Split the PHC format: $argon2id$v=19$m=65536,t=3,p=4$salt$hash
if (parts.Length != 2) 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; return false;
byte[] salt = Convert.FromBase64String(parts[0]); int memory = 0,
byte[] expected = Convert.FromBase64String(parts[1]); 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, Salt = salt,
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1), DegreeOfParallelism = parallelism,
MemorySize = ArgonMemoryKb, MemorySize = memory,
Iterations = ArgonIterations, Iterations = iterations,
}; };
byte[] actual = argon2.GetBytes(expected.Length); byte[] actualHash = argon2.GetBytes(expectedHash.Length);
return CryptographicOperations.FixedTimeEquals(actual, expected);
return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash);
} }
catch catch
{ {
return false; return false;
} }
} }
/// <summary>
/// .NET's Convert.FromBase64String requires padding. The PHC format drops padding.
/// This helper restores the padding before converting.
/// </summary>
private static byte[] DecodeBase64NoPadding(string base64)
{
string padded = base64.PadRight(base64.Length + (4 - base64.Length % 4) % 4, '=');
return Convert.FromBase64String(padded);
}
} }