| | | 1 | | using System.Buffers; |
| | | 2 | | using System.Buffers.Text; |
| | | 3 | | using System.Security.Cryptography; |
| | | 4 | | using System.Text; |
| | | 5 | | using Elsa.Identity.Contracts; |
| | | 6 | | using Elsa.Identity.Models; |
| | | 7 | | |
| | | 8 | | namespace Elsa.Identity.Services; |
| | | 9 | | |
| | | 10 | | /// <inheritdoc /> |
| | | 11 | | public class DefaultSecretHasher : ISecretHasher |
| | | 12 | | { |
| | | 13 | | private const string Algorithm = "pbkdf2-sha256"; |
| | | 14 | | private const char Separator = '$'; |
| | | 15 | | private const byte SeparatorByte = (byte)Separator; |
| | | 16 | | private const int DefaultIterationCount = 600_000; |
| | | 17 | | private const int MaxIterationCount = DefaultIterationCount * 4; |
| | | 18 | | private const int KeySize = 32; |
| | 1 | 19 | | private static readonly byte[] AlgorithmBytes = Encoding.UTF8.GetBytes(Algorithm); |
| | | 20 | | |
| | | 21 | | /// <inheritdoc /> |
| | | 22 | | public HashedSecret HashSecret(string secret) |
| | | 23 | | { |
| | 3 | 24 | | var saltBytes = GenerateSalt(); |
| | 3 | 25 | | return HashSecret(secret, saltBytes); |
| | | 26 | | } |
| | | 27 | | |
| | | 28 | | /// <inheritdoc /> |
| | | 29 | | public HashedSecret HashSecret(string secret, byte[] salt) |
| | | 30 | | { |
| | 3 | 31 | | var passwordBytes = Encoding.UTF8.GetBytes(secret); |
| | | 32 | | try |
| | | 33 | | { |
| | 3 | 34 | | var hashedPassword = HashSecret(passwordBytes, salt); |
| | 3 | 35 | | return HashedSecret.FromBytes(hashedPassword, salt); |
| | | 36 | | } |
| | | 37 | | finally |
| | | 38 | | { |
| | 3 | 39 | | CryptographicOperations.ZeroMemory(passwordBytes); |
| | 3 | 40 | | } |
| | 3 | 41 | | } |
| | | 42 | | |
| | | 43 | | /// <inheritdoc /> |
| | | 44 | | public bool VerifySecret(string clearTextSecret, string secret, string salt) |
| | | 45 | | { |
| | 0 | 46 | | var hashedPassword = HashedSecret.FromString(secret, salt); |
| | 0 | 47 | | return VerifySecret(clearTextSecret, hashedPassword); |
| | | 48 | | } |
| | | 49 | | |
| | | 50 | | /// <inheritdoc /> |
| | | 51 | | public bool VerifySecret(string clearTextSecret, string secret, string salt, out bool needsRehash) |
| | | 52 | | { |
| | 1 | 53 | | var hashedPassword = HashedSecret.FromString(secret, salt); |
| | 1 | 54 | | return VerifySecret(clearTextSecret, hashedPassword, out needsRehash); |
| | | 55 | | } |
| | | 56 | | |
| | | 57 | | /// <inheritdoc /> |
| | | 58 | | public bool VerifySecret(string clearTextSecret, HashedSecret hashedSecret) |
| | | 59 | | { |
| | 0 | 60 | | return VerifySecret(clearTextSecret, hashedSecret, out _); |
| | | 61 | | } |
| | | 62 | | |
| | | 63 | | /// <inheritdoc /> |
| | | 64 | | public bool VerifySecret(string clearTextSecret, HashedSecret hashedSecret, out bool needsRehash) |
| | | 65 | | { |
| | 9 | 66 | | var storedSecretBytes = hashedSecret.Secret; |
| | 9 | 67 | | var saltBytes = hashedSecret.Salt; |
| | 9 | 68 | | var clearTextBytes = Encoding.UTF8.GetBytes(clearTextSecret); |
| | 9 | 69 | | Span<byte> expectedHash = stackalloc byte[KeySize]; |
| | 9 | 70 | | byte[]? providedHash = null; |
| | 9 | 71 | | byte[]? legacyHash = null; |
| | | 72 | | |
| | | 73 | | try |
| | | 74 | | { |
| | 9 | 75 | | if (TryReadPbkdf2Hash(storedSecretBytes, expectedHash, out var iterationCount)) |
| | | 76 | | { |
| | 5 | 77 | | providedHash = HashSecret(clearTextBytes, saltBytes, iterationCount); |
| | 5 | 78 | | var matches = CryptographicOperations.FixedTimeEquals(providedHash, expectedHash); |
| | 5 | 79 | | needsRehash = matches && iterationCount < DefaultIterationCount; |
| | 5 | 80 | | return matches; |
| | | 81 | | } |
| | | 82 | | |
| | 4 | 83 | | legacyHash = HashLegacySha256(clearTextBytes, saltBytes); |
| | 4 | 84 | | if (storedSecretBytes.Length != legacyHash.Length) |
| | | 85 | | { |
| | 3 | 86 | | needsRehash = false; |
| | 3 | 87 | | return false; |
| | | 88 | | } |
| | | 89 | | |
| | 1 | 90 | | var isLegacyMatch = CryptographicOperations.FixedTimeEquals(legacyHash, storedSecretBytes); |
| | 1 | 91 | | needsRehash = isLegacyMatch; |
| | 1 | 92 | | return isLegacyMatch; |
| | | 93 | | } |
| | | 94 | | finally |
| | | 95 | | { |
| | 9 | 96 | | CryptographicOperations.ZeroMemory(clearTextBytes); |
| | | 97 | | |
| | 9 | 98 | | if (providedHash is not null) |
| | 5 | 99 | | CryptographicOperations.ZeroMemory(providedHash); |
| | | 100 | | |
| | 9 | 101 | | if (legacyHash is not null) |
| | 4 | 102 | | CryptographicOperations.ZeroMemory(legacyHash); |
| | | 103 | | |
| | 9 | 104 | | CryptographicOperations.ZeroMemory(expectedHash); |
| | 9 | 105 | | } |
| | 9 | 106 | | } |
| | | 107 | | |
| | | 108 | | /// <inheritdoc /> |
| | | 109 | | public byte[] HashSecret(byte[] secret, byte[] salt) |
| | | 110 | | { |
| | 3 | 111 | | var hash = HashSecret(secret, salt, DefaultIterationCount); |
| | | 112 | | try |
| | | 113 | | { |
| | 3 | 114 | | return FormatHashEnvelope(hash); |
| | | 115 | | } |
| | | 116 | | finally |
| | | 117 | | { |
| | 3 | 118 | | CryptographicOperations.ZeroMemory(hash); |
| | 3 | 119 | | } |
| | 3 | 120 | | } |
| | | 121 | | |
| | | 122 | | /// <inheritdoc /> |
| | 7 | 123 | | public byte[] GenerateSalt(int saltSize = 32) => RandomNumberGenerator.GetBytes(saltSize); |
| | | 124 | | |
| | | 125 | | private static byte[] HashSecret(byte[] secret, byte[] salt, int iterationCount) |
| | | 126 | | { |
| | 8 | 127 | | return Rfc2898DeriveBytes.Pbkdf2(secret, salt, iterationCount, HashAlgorithmName.SHA256, KeySize); |
| | | 128 | | } |
| | | 129 | | |
| | | 130 | | private static byte[] FormatHashEnvelope(byte[] hash) |
| | | 131 | | { |
| | 3 | 132 | | Span<byte> iterationBytes = stackalloc byte[16]; |
| | 3 | 133 | | if (!Utf8Formatter.TryFormat(DefaultIterationCount, iterationBytes, out var iterationBytesWritten)) |
| | 0 | 134 | | throw new InvalidOperationException("Failed to format the PBKDF2 iteration count."); |
| | | 135 | | |
| | 3 | 136 | | var encodedHashLength = ((hash.Length + 2) / 3) * 4; |
| | 3 | 137 | | var result = new byte[AlgorithmBytes.Length + 1 + iterationBytesWritten + 1 + encodedHashLength]; |
| | 3 | 138 | | var resultSpan = result.AsSpan(); |
| | 3 | 139 | | AlgorithmBytes.CopyTo(resultSpan); |
| | | 140 | | |
| | 3 | 141 | | var offset = AlgorithmBytes.Length; |
| | 3 | 142 | | resultSpan[offset++] = SeparatorByte; |
| | 3 | 143 | | iterationBytes[..iterationBytesWritten].CopyTo(resultSpan[offset..]); |
| | 3 | 144 | | offset += iterationBytesWritten; |
| | 3 | 145 | | resultSpan[offset++] = SeparatorByte; |
| | | 146 | | |
| | 3 | 147 | | var status = Base64.EncodeToUtf8(hash, resultSpan[offset..], out var consumed, out var written); |
| | 3 | 148 | | if (status != OperationStatus.Done || consumed != hash.Length || written != encodedHashLength) |
| | 0 | 149 | | throw new InvalidOperationException("Failed to encode the PBKDF2 hash."); |
| | | 150 | | |
| | 3 | 151 | | return result; |
| | | 152 | | } |
| | | 153 | | |
| | | 154 | | private static byte[] HashLegacySha256(byte[] secret, byte[] salt) |
| | | 155 | | { |
| | 4 | 156 | | using var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); |
| | 4 | 157 | | sha256.AppendData(secret); |
| | 4 | 158 | | sha256.AppendData(salt); |
| | 4 | 159 | | return sha256.GetHashAndReset(); |
| | 4 | 160 | | } |
| | | 161 | | |
| | | 162 | | private static bool TryReadPbkdf2Hash(byte[] storedSecretBytes, Span<byte> hash, out int iterationCount) |
| | | 163 | | { |
| | 9 | 164 | | iterationCount = 0; |
| | | 165 | | |
| | 9 | 166 | | var envelope = storedSecretBytes.AsSpan(); |
| | 9 | 167 | | var algorithmSeparatorIndex = envelope.IndexOf(SeparatorByte); |
| | 9 | 168 | | if (algorithmSeparatorIndex <= 0) |
| | 1 | 169 | | return false; |
| | | 170 | | |
| | 8 | 171 | | var algorithmBytes = envelope[..algorithmSeparatorIndex]; |
| | 8 | 172 | | if (!algorithmBytes.SequenceEqual(AlgorithmBytes)) |
| | 1 | 173 | | return false; |
| | | 174 | | |
| | 7 | 175 | | var iterationAndHashBytes = envelope[(algorithmSeparatorIndex + 1)..]; |
| | 7 | 176 | | var iterationSeparatorIndex = iterationAndHashBytes.IndexOf(SeparatorByte); |
| | 7 | 177 | | if (iterationSeparatorIndex <= 0) |
| | 0 | 178 | | return false; |
| | | 179 | | |
| | 7 | 180 | | var iterationBytes = iterationAndHashBytes[..iterationSeparatorIndex]; |
| | 7 | 181 | | if (!Utf8Parser.TryParse(iterationBytes, out iterationCount, out var bytesConsumed) || bytesConsumed != iteratio |
| | 1 | 182 | | return false; |
| | | 183 | | |
| | 6 | 184 | | var encodedHashBytes = iterationAndHashBytes[(iterationSeparatorIndex + 1)..]; |
| | 6 | 185 | | var status = Base64.DecodeFromUtf8(encodedHashBytes, hash, out var consumed, out var written); |
| | 6 | 186 | | if (status == OperationStatus.Done && consumed == encodedHashBytes.Length && written == KeySize) |
| | 5 | 187 | | return true; |
| | | 188 | | |
| | 1 | 189 | | iterationCount = 0; |
| | 1 | 190 | | CryptographicOperations.ZeroMemory(hash); |
| | 1 | 191 | | return false; |
| | | 192 | | } |
| | | 193 | | } |