mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Switch Argon2 hashes to PHC string format (#256)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user