using System.Security.Cryptography; using System.Text; using Konscious.Security.Cryptography; namespace Azaion.Services; // Password hashing — Argon2id (RFC 9106) for new + lazy migration of legacy SHA-384. // Stored format: PHC string `$argon2id$v=19$m=,t=,p=$$`. // Legacy format: 64-char base64 of unsalted SHA-384 (no `$` prefix). Detected by prefix. // // AZ-536 (Epic AZ-530, CMMC IA.L2-3.5.10). public static class Security { // Conservative defaults per RFC 9106 §4. Bump in the future and the verify path // will surface NeedsRehash=true for any hash whose params are weaker. private const int Argon2MemoryKib = 65536; // 64 MiB private const int Argon2Iterations = 3; private const int Argon2Parallelism = 1; private const int SaltLengthBytes = 16; // 128 bits — RFC 9106 recommended minimum private const int HashLengthBytes = 32; // 256 bits private const string PhcPrefix = "$argon2id$"; private const int LegacySha384B64Length = 64; // Convert.ToBase64String(48 bytes) == 64 chars public sealed record VerifyResult(bool Valid, bool NeedsRehash); public static string HashPassword(string plaintext) { if (plaintext == null) throw new ArgumentNullException(nameof(plaintext)); var salt = RandomNumberGenerator.GetBytes(SaltLengthBytes); var hash = ComputeArgon2id(plaintext, salt, Argon2MemoryKib, Argon2Iterations, Argon2Parallelism); return EncodePhc(Argon2MemoryKib, Argon2Iterations, Argon2Parallelism, salt, hash); } public static VerifyResult VerifyPassword(string plaintext, string stored) { if (plaintext == null) throw new ArgumentNullException(nameof(plaintext)); if (string.IsNullOrEmpty(stored)) return new VerifyResult(Valid: false, NeedsRehash: false); if (stored.StartsWith(PhcPrefix, StringComparison.Ordinal)) { if (!TryDecodePhc(stored, out var p)) return new VerifyResult(Valid: false, NeedsRehash: false); var candidate = ComputeArgon2id(plaintext, p.Salt, p.MemoryKib, p.Iterations, p.Parallelism); var valid = CryptographicOperations.FixedTimeEquals(candidate, p.Hash); // NeedsRehash true if defaults are stronger than the stored params — supports later upgrades. var needsRehash = valid && (p.MemoryKib < Argon2MemoryKib || p.Iterations < Argon2Iterations || p.Parallelism < Argon2Parallelism); return new VerifyResult(valid, needsRehash); } if (IsLegacySha384(stored)) { var legacyHash = SHA384.HashData(Encoding.UTF8.GetBytes(plaintext)); var legacyB64Bytes = Encoding.ASCII.GetBytes(Convert.ToBase64String(legacyHash)); var storedBytes = Encoding.ASCII.GetBytes(stored); var valid = storedBytes.Length == legacyB64Bytes.Length && CryptographicOperations.FixedTimeEquals(storedBytes, legacyB64Bytes); return new VerifyResult(valid, NeedsRehash: valid); } return new VerifyResult(Valid: false, NeedsRehash: false); } private static bool IsLegacySha384(string stored) => stored.Length == LegacySha384B64Length && !stored.StartsWith('$'); private static byte[] ComputeArgon2id(string plaintext, byte[] salt, int memoryKib, int iterations, int parallelism) { using var argon = new Argon2id(Encoding.UTF8.GetBytes(plaintext)) { Salt = salt, MemorySize = memoryKib, Iterations = iterations, DegreeOfParallelism = parallelism }; return argon.GetBytes(HashLengthBytes); } private static string EncodePhc(int memoryKib, int iterations, int parallelism, byte[] salt, byte[] hash) => $"$argon2id$v=19$m={memoryKib},t={iterations},p={parallelism}${ToB64NoPad(salt)}${ToB64NoPad(hash)}"; private static bool TryDecodePhc(string stored, out PhcParams parsed) { parsed = default!; // $argon2id$v=19$m=65536,t=3,p=1$$ var parts = stored.Split('$'); if (parts.Length != 6) return false; if (parts[1] != "argon2id") return false; if (parts[2] != "v=19") return false; var paramFields = parts[3].Split(','); if (paramFields.Length != 3) return false; if (!TryParseKv(paramFields[0], "m", out var m)) return false; if (!TryParseKv(paramFields[1], "t", out var t)) return false; if (!TryParseKv(paramFields[2], "p", out var p)) return false; if (!TryFromB64NoPad(parts[4], out var salt)) return false; if (!TryFromB64NoPad(parts[5], out var hash)) return false; parsed = new PhcParams(m, t, p, salt, hash); return true; } private static bool TryParseKv(string field, string key, out int value) { value = 0; var eq = field.IndexOf('='); if (eq <= 0 || field[..eq] != key) return false; return int.TryParse(field.AsSpan(eq + 1), out value) && value > 0; } private static string ToB64NoPad(byte[] bytes) => Convert.ToBase64String(bytes).TrimEnd('='); private static bool TryFromB64NoPad(string s, out byte[] bytes) { var padded = s.Length % 4 == 0 ? s : s + new string('=', 4 - s.Length % 4); try { bytes = Convert.FromBase64String(padded); return true; } catch (FormatException) { bytes = Array.Empty(); return false; } } private readonly record struct PhcParams(int MemoryKib, int Iterations, int Parallelism, byte[] Salt, byte[] Hash); }