|
1 | 1 | using System; |
2 | | -using System.Linq; |
3 | 2 | using System.Text; |
4 | 3 | using System.Security.Cryptography; |
5 | 4 |
|
6 | 5 | namespace EasyExtensions.Crypto |
7 | 6 | { |
8 | 7 | /// <summary> |
9 | | - /// Provides methods for deriving cryptographic subkeys from a master key and a specified purpose using HMAC-SHA256. |
| 8 | + /// Key derivation using HKDF (RFC 5869) over HMAC-SHA256. |
| 9 | + /// Provides deterministic subkeys from a master key and context info (purpose), with optional salt. |
10 | 10 | /// </summary> |
11 | | - /// <remarks>The KeyDerivation class enables deterministic generation of subkeys for different application |
12 | | - /// scenarios, such as per-user or per-purpose secrets, by combining a master key with a unique purpose string. This |
13 | | - /// approach helps ensure that derived keys are unique and isolated for each intended use. All members are static |
14 | | - /// and thread-safe.</remarks> |
15 | 11 | public static class KeyDerivation |
16 | 12 | { |
| 13 | + private const int HmacOutputLength = 32; // HMAC-SHA256 output size |
| 14 | + |
17 | 15 | /// <summary> |
18 | | - /// Derives a deterministic subkey from the specified master key and purpose using HMAC-SHA256. |
19 | | - /// Do not use this method for password hashing or storage. |
| 16 | + /// HKDF (RFC 5869) over HMAC-SHA256: masterKey + info (+ optional salt) -> subkey. |
20 | 17 | /// </summary> |
21 | | - /// <remarks>This method uses HMAC-SHA256 to derive a subkey that is unique to the given purpose |
22 | | - /// and master key. If the requested length exceeds 32 bytes, additional HMAC blocks are concatenated to reach |
23 | | - /// the desired length. The method is suitable for generating cryptographic keys for different contexts from a |
24 | | - /// single master key. The caller is responsible for ensuring that the master key and purpose are chosen |
25 | | - /// appropriately for their security requirements.</remarks> |
26 | | - /// <param name="masterKey">The master key as a UTF-8 encoded string. Cannot be null.</param> |
27 | | - /// <param name="purpose">A string that specifies the intended purpose of the derived subkey. This value differentiates subkeys |
28 | | - /// derived from the same master key. Cannot be null.</param> |
29 | | - /// <param name="lengthBytes">The desired length, in bytes, of the derived subkey. Must be a positive integer.</param> |
30 | | - /// <returns>A byte array containing the derived subkey of the specified length. The same inputs will always produce the |
31 | | - /// same output.</returns> |
32 | | - public static byte[] DeriveSubkey(string masterKey, string purpose, int lengthBytes) |
| 18 | + public static byte[] DeriveSubkey( |
| 19 | + ReadOnlySpan<byte> masterKey, |
| 20 | + ReadOnlySpan<byte> info, |
| 21 | + int lengthBytes, |
| 22 | + ReadOnlySpan<byte> salt = default) |
33 | 23 | { |
34 | | - // masterKey + purpose -> HMAC-SHA256 -> target length |
35 | | - var masterBytes = Encoding.UTF8.GetBytes(masterKey); |
36 | | - var purposeBytes = Encoding.UTF8.GetBytes(purpose); |
| 24 | + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(lengthBytes); |
37 | 25 |
|
38 | | - using var hmac = new HMACSHA256(masterBytes); |
39 | | - var hash = hmac.ComputeHash(purposeBytes); |
| 26 | + // RFC 5869: Extract |
| 27 | + var prk = HkdfExtract(masterKey, salt); |
| 28 | + try |
| 29 | + { |
| 30 | + // RFC 5869: Expand |
| 31 | + return HkdfExpand(prk, info, lengthBytes); |
| 32 | + } |
| 33 | + finally |
| 34 | + { |
| 35 | + CryptographicOperations.ZeroMemory(prk); |
| 36 | + } |
| 37 | + } |
40 | 38 |
|
41 | | - if (lengthBytes <= hash.Length) |
| 39 | + private static byte[] HkdfExtract(ReadOnlySpan<byte> ikm, ReadOnlySpan<byte> salt) |
| 40 | + { |
| 41 | + // If salt is not provided, use zeros of HashLen bytes (RFC 5869 §2.2). |
| 42 | + byte[] saltKey = salt.IsEmpty ? new byte[HmacOutputLength] : salt.ToArray(); |
| 43 | + try |
| 44 | + { |
| 45 | + using var hmac = new HMACSHA256(saltKey); |
| 46 | + // ComputeHash allocates; we pass a copy of ikm to avoid pinning external span |
| 47 | + return hmac.ComputeHash(ikm.ToArray()); |
| 48 | + } |
| 49 | + finally |
| 50 | + { |
| 51 | + CryptographicOperations.ZeroMemory(saltKey); |
| 52 | + } |
| 53 | + } |
| 54 | + |
| 55 | + private static byte[] HkdfExpand(byte[] prk, ReadOnlySpan<byte> info, int lengthBytes) |
| 56 | + { |
| 57 | + int n = (int)Math.Ceiling(lengthBytes / (double)HmacOutputLength); |
| 58 | + if (n <= 0 || n > 255) |
42 | 59 | { |
43 | | - var result = new byte[lengthBytes]; |
44 | | - Array.Copy(hash, result, lengthBytes); |
45 | | - return result; |
| 60 | + throw new ArgumentOutOfRangeException(nameof(lengthBytes), "HKDF length is too large."); |
46 | 61 | } |
47 | 62 |
|
48 | | - // If you need more than 32 bytes, you can simply "pull" more blocks |
49 | | - // with different counters. This allows for generating longer keys |
50 | | - // while still being deterministic and tied to the master key and purpose. |
51 | | - var buffer = new byte[lengthBytes]; |
| 63 | + var okm = new byte[lengthBytes]; |
| 64 | + var infoBytes = info.ToArray(); |
| 65 | + var t = Array.Empty<byte>(); |
52 | 66 | int offset = 0; |
53 | | - int counter = 1; |
54 | 67 |
|
55 | | - while (offset < lengthBytes) |
| 68 | + using var hmac = new HMACSHA256(prk); |
| 69 | + |
| 70 | + for (int i = 1; i <= n; i++) |
56 | 71 | { |
57 | | - var counterBytes = BitConverter.GetBytes(counter++); |
58 | | - var blockInput = purposeBytes.Concat(counterBytes).ToArray(); |
59 | | - var block = hmac.ComputeHash(blockInput); |
| 72 | + // T(i) = HMAC(PRK, T(i-1) || info || i) |
| 73 | + var data = new byte[t.Length + infoBytes.Length + 1]; |
| 74 | + Buffer.BlockCopy(t, 0, data, 0, t.Length); |
| 75 | + Buffer.BlockCopy(infoBytes, 0, data, t.Length, infoBytes.Length); |
| 76 | + data[^1] = (byte)i; |
| 77 | + |
| 78 | + t = hmac.ComputeHash(data); |
60 | 79 |
|
61 | | - int toCopy = Math.Min(block.Length, lengthBytes - offset); |
62 | | - Array.Copy(block, 0, buffer, offset, toCopy); |
| 80 | + int toCopy = Math.Min(HmacOutputLength, lengthBytes - offset); |
| 81 | + Buffer.BlockCopy(t, 0, okm, offset, toCopy); |
63 | 82 | offset += toCopy; |
64 | 83 | } |
65 | 84 |
|
66 | | - return buffer; |
| 85 | + CryptographicOperations.ZeroMemory(t); |
| 86 | + CryptographicOperations.ZeroMemory(infoBytes); |
| 87 | + return okm; |
| 88 | + } |
| 89 | + |
| 90 | + /// <summary> |
| 91 | + /// String-based wrapper for compatibility: masterKey + purpose -> subkey. |
| 92 | + /// </summary> |
| 93 | + public static byte[] DeriveSubkey( |
| 94 | + string masterKey, |
| 95 | + string purpose, |
| 96 | + int lengthBytes, |
| 97 | + string? salt = null) |
| 98 | + { |
| 99 | + ArgumentNullException.ThrowIfNull(masterKey); |
| 100 | + ArgumentNullException.ThrowIfNull(purpose); |
| 101 | + |
| 102 | + var masterBytes = Encoding.UTF8.GetBytes(masterKey); |
| 103 | + var infoBytes = Encoding.UTF8.GetBytes(purpose); |
| 104 | + byte[]? saltBytes = salt is null ? null : Encoding.UTF8.GetBytes(salt); |
| 105 | + |
| 106 | + try |
| 107 | + { |
| 108 | + return DeriveSubkey( |
| 109 | + masterBytes, |
| 110 | + infoBytes, |
| 111 | + lengthBytes, |
| 112 | + saltBytes is null ? [] : saltBytes); |
| 113 | + } |
| 114 | + finally |
| 115 | + { |
| 116 | + CryptographicOperations.ZeroMemory(masterBytes); |
| 117 | + CryptographicOperations.ZeroMemory(infoBytes); |
| 118 | + if (saltBytes is not null) |
| 119 | + { |
| 120 | + CryptographicOperations.ZeroMemory(saltBytes); |
| 121 | + } |
| 122 | + } |
67 | 123 | } |
68 | 124 |
|
69 | 125 | /// <summary> |
70 | | - /// Derives a subkey from the specified master key and purpose, and returns the result as a Base64-encoded |
71 | | - /// string. |
| 126 | + /// Base64 helper. |
72 | 127 | /// </summary> |
73 | | - /// <param name="masterKey">The master key used as the basis for subkey derivation. Cannot be null or empty.</param> |
74 | | - /// <param name="purpose">A string that specifies the intended purpose of the derived subkey. Used to ensure subkeys are unique for |
75 | | - /// different purposes. Cannot be null or empty.</param> |
76 | | - /// <param name="lengthBytes">The desired length, in bytes, of the derived subkey. Must be a positive integer.</param> |
77 | | - /// <returns>A Base64-encoded string representing the derived subkey.</returns> |
78 | | - public static string DeriveSubkeyBase64(string masterKey, string purpose, int lengthBytes) |
| 128 | + public static string DeriveSubkeyBase64( |
| 129 | + string masterKey, |
| 130 | + string purpose, |
| 131 | + int lengthBytes, |
| 132 | + string? salt = null) |
79 | 133 | { |
80 | | - var bytes = DeriveSubkey(masterKey, purpose, lengthBytes); |
| 134 | + var bytes = DeriveSubkey(masterKey, purpose, lengthBytes, salt); |
81 | 135 | return Convert.ToBase64String(bytes); |
82 | 136 | } |
83 | 137 | } |
|
0 commit comments