up
This commit is contained in:
173
src/StellaOps.Cryptography/Argon2idPasswordHasher.cs
Normal file
173
src/StellaOps.Cryptography/Argon2idPasswordHasher.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace StellaOps.Cryptography;
|
||||
|
||||
/// <summary>
|
||||
/// Argon2id password hasher that emits PHC-compliant encoded strings.
|
||||
/// </summary>
|
||||
public sealed partial class Argon2idPasswordHasher : IPasswordHasher
|
||||
{
|
||||
private const int SaltLengthBytes = 16;
|
||||
private const int HashLengthBytes = 32;
|
||||
|
||||
public string Hash(string password, PasswordHashOptions options)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(password);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
options.Validate();
|
||||
|
||||
if (options.Algorithm != PasswordHashAlgorithm.Argon2id)
|
||||
{
|
||||
throw new InvalidOperationException("Argon2idPasswordHasher only supports the Argon2id algorithm.");
|
||||
}
|
||||
|
||||
Span<byte> salt = stackalloc byte[SaltLengthBytes];
|
||||
RandomNumberGenerator.Fill(salt);
|
||||
|
||||
var hash = DeriveHash(password, salt, options);
|
||||
|
||||
return BuildEncodedHash(salt, hash, options);
|
||||
}
|
||||
|
||||
public bool Verify(string password, string encodedHash)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(password);
|
||||
ArgumentException.ThrowIfNullOrEmpty(encodedHash);
|
||||
|
||||
if (!TryParse(encodedHash, out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var computed = DeriveHash(password, parsed.Salt, parsed.Options);
|
||||
return CryptographicOperations.FixedTimeEquals(computed, parsed.Hash);
|
||||
}
|
||||
|
||||
public bool NeedsRehash(string encodedHash, PasswordHashOptions desired)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(desired);
|
||||
|
||||
if (!TryParse(encodedHash, out var parsed))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (desired.Algorithm != PasswordHashAlgorithm.Argon2id)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!parsed.Options.Algorithm.Equals(desired.Algorithm))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return parsed.Options.MemorySizeInKib != desired.MemorySizeInKib
|
||||
|| parsed.Options.Iterations != desired.Iterations
|
||||
|| parsed.Options.Parallelism != desired.Parallelism;
|
||||
}
|
||||
|
||||
private static byte[] DeriveHash(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options)
|
||||
=> DeriveHashCore(password, salt, options);
|
||||
|
||||
private static partial byte[] DeriveHashCore(string password, ReadOnlySpan<byte> salt, PasswordHashOptions options);
|
||||
|
||||
private static string BuildEncodedHash(ReadOnlySpan<byte> salt, ReadOnlySpan<byte> hash, PasswordHashOptions options)
|
||||
{
|
||||
var saltEncoded = Convert.ToBase64String(salt);
|
||||
var hashEncoded = Convert.ToBase64String(hash);
|
||||
|
||||
return $"$argon2id$v=19$m={options.MemorySizeInKib},t={options.Iterations},p={options.Parallelism}${saltEncoded}${hashEncoded}";
|
||||
}
|
||||
|
||||
private static bool TryParse(string encodedHash, out Argon2HashParameters parsed)
|
||||
{
|
||||
parsed = default;
|
||||
|
||||
if (!encodedHash.StartsWith("$argon2id$", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var segments = encodedHash.Split('$', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length != 5)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// segments: 0=argon2id, 1=v=19, 2=m=...,t=...,p=..., 3=salt, 4=hash
|
||||
if (!segments[1].StartsWith("v=19", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parameterParts = segments[2].Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parameterParts.Length != 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryParseInt(parameterParts[0], "m", out var memory) ||
|
||||
!TryParseInt(parameterParts[1], "t", out var iterations) ||
|
||||
!TryParseInt(parameterParts[2], "p", out var parallelism))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] saltBytes;
|
||||
byte[] hashBytes;
|
||||
try
|
||||
{
|
||||
saltBytes = Convert.FromBase64String(segments[3]);
|
||||
hashBytes = Convert.FromBase64String(segments[4]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (saltBytes.Length != SaltLengthBytes || hashBytes.Length != HashLengthBytes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var options = new PasswordHashOptions
|
||||
{
|
||||
Algorithm = PasswordHashAlgorithm.Argon2id,
|
||||
MemorySizeInKib = memory,
|
||||
Iterations = iterations,
|
||||
Parallelism = parallelism
|
||||
};
|
||||
|
||||
parsed = new Argon2HashParameters(options, saltBytes, hashBytes);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseInt(string component, string key, out int value)
|
||||
{
|
||||
value = 0;
|
||||
if (!component.StartsWith(key + "=", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return int.TryParse(component.AsSpan(key.Length + 1), NumberStyles.None, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
|
||||
private readonly struct Argon2HashParameters
|
||||
{
|
||||
public Argon2HashParameters(PasswordHashOptions options, byte[] salt, byte[] hash)
|
||||
{
|
||||
Options = options;
|
||||
Salt = salt;
|
||||
Hash = hash;
|
||||
}
|
||||
|
||||
public PasswordHashOptions Options { get; }
|
||||
public byte[] Salt { get; }
|
||||
public byte[] Hash { get; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user