using System; using System.Globalization; using System.Security.Cryptography; namespace StellaOps.Cryptography; /// /// Argon2id password hasher that emits PHC-compliant encoded strings. /// 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 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 salt, PasswordHashOptions options) => DeriveHashCore(password, salt, options); private static partial byte[] DeriveHashCore(string password, ReadOnlySpan salt, PasswordHashOptions options); private static string BuildEncodedHash(ReadOnlySpan salt, ReadOnlySpan 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; } } }