Based on my prior question about HKDF here https://crypto.stackexchange.com/questions/43933/use-of-pbkdf2-when-no-access-to-hkdf and the responses I received I decided to go ahead an implement HKDF based on the rfc5869 spec https://www.rfc-editor.org/rfc/rfc5869 since HKDF wasn't available to me in the .net framework or from a trusted source. I have tested it against the three tests listed in the spec and provided the xUnit code below my HKDF source code. Three tests doesn't seem like much to me though, so I'd value your review and comments.
Updated on 4/13/20 to swap the params of the Extract method which was a bug in the posted code pointed out by Anonymous. It's unclear how that got transposed given that even the code comments show the params in the correct order, and the unit tests call the method in the correct order and had passed at one time. So I'm baffled. Anyway, I appreciate the mistake being pointed out in the posted code and have fixed it below. Specifically the method is corrected to be
public static byte[] Extract(byte[] salt, byte[] inputKeyMaterial)
rather than public static byte[] Extract(byte[] inputKeyMaterial, byte[] salt)
.
using System;
using System.Security.Cryptography;
namespace App.Security {
/// <summary>
/// This class implements rfc5869 HMAC-based Extract-and-Expand Key Derivation Function
/// (HKDF) using HMACSHA256.
/// Reference: https://www.rfc-editor.org/rfc/rfc5869
/// </summary>
public class HKDF {
/// <summary>
/// Returns a 32 byte psuedorandom number that can be used with the Expand method if
/// a cryptographically secure pseudorandom number is not already available.
/// </summary>
/// <param name="salt">(Optional, but you should use it) Non-secret random value.
/// If less than 64 bytes it is padded with zeros. Can be reused but output is
/// stronger if not reused. (And of course output is much stronger with salt than
/// without it)</param>
/// <param name="inputKeyMaterial">Material that is not necessarily random that
/// will be used with the HMACSHA256 hash function and the salt to produce
/// a 32 byte psuedorandom number.</param>
/// <returns></returns>
public static byte[] Extract(byte[] salt, byte[] inputKeyMaterial) {
//For algorithm docs, see section 2.2: https://www.rfc-editor.org/rfc/rfc5869
using(HMACSHA256 hmac = new HMACSHA256(salt)) {
return hmac.ComputeHash(inputKeyMaterial, offset: 0, count: inputKeyMaterial.Length);
}
}
/// <summary>
/// Returns a secure pseudorandom key of the desired length. Useful as a key derivation
/// function to derive one cryptograpically secure pseudorandom key from another
/// cryptograpically secure pseudorandom key. This can be useful, for example,
/// when needing to create a subKey from a master key.
/// </summary>
/// <param name="key">A cryptograpically secure pseudorandom number. Can be obtained
/// via the Extract method or elsewhere. Must be 32 bytes or greater. 64 bytes is
/// the prefered size. Shorter keys are padded to 64 bytes, longer ones are hashed
/// to 64 bytes.</param>
/// <param name="info">(Optional) Context and application specific information.
/// Allows the output to be bound to application context related information.</param>
/// <param name="length">Length of output in bytes.</param>
/// <returns></returns>
public static byte[] Expand(byte[] key, byte[] info, int length) {
//For algorithm docs, see section 2.3: https://www.rfc-editor.org/rfc/rfc5869
//Also note:
// SHA256 has a block size of 64 bytes
// SHA256 has an output length of 32 bytes (but can be truncated to less)
const int hashLength = 32;
//Min recommended length for Key is the size of the hash output (32 bytes in this case)
//See section 2: https://www.rfc-editor.org/rfc/rfc2104#section-3
//Also see: http://security.stackexchange.com/questions/95972/what-are-requirements-for-hmac-secret-key
if(key == null || key.Length < 32) {
throw new ArgumentOutOfRangeException("Key should be 32 bytes or greater.");
}
if(length > 255 * hashLength) {
throw new ArgumentOutOfRangeException("Output length must 8160 bytes or less which is 255 * the SHA256 block site of 32 bytes.");
}
int outputIndex = 0;
byte[] buffer;
byte[] hash = new byte[0];
byte[] output = new byte[length];
int count = 1;
int bytesToCopy;
using(HMACSHA256 hmac = new HMACSHA256(key)) {
while(outputIndex < length) {
//Setup buffer to hash
buffer = new byte[hash.Length + info.Length + 1];
Buffer.BlockCopy(hash, 0, buffer, 0, hash.Length);
Buffer.BlockCopy(info, 0, buffer, hash.Length, info.Length);
buffer[buffer.Length - 1] = (byte) count++;
//Hash the buffer and return a 32 byte hash
hash = hmac.ComputeHash(buffer, offset: 0, count: buffer.Length);
//Copy as much of the hash as we need to the final output
bytesToCopy = Math.Min(length - outputIndex, hash.Length);
Buffer.BlockCopy(hash, 0, output, outputIndex, bytesToCopy);
outputIndex += bytesToCopy;
}
}
return output;
}
/// <summary>
/// Generates a psuedorandom number of the length specified. This number is suitable
/// for use as an encryption key, HMAC validation key or other uses of a cryptographically
/// secure psuedorandom number.
/// </summary>
/// <param name="salt">non-secret random value. If less than 64 bytes it is
/// padded with zeros. Can be reused but output is stronger if not reused.</param>
/// <param name="inputKeyMaterial">Material that is not necessarily random that
/// will be used with the HMACSHA256 hash function and the salt to produce
/// a 32 byte psuedorandom number.</param>
/// <param name="info">(Optional) context and application specific information.
/// Allows the output to be bound to application context related information. Pass 0 length
/// byte array to omit.</param>
/// <param name="length">Length of output in bytes.</param>
public static byte[] GetBytes(byte[] salt, byte[] inputKeyMaterial, byte[] info, int length) {
byte[] key = Extract(salt, inputKeyMaterial);
return Expand(key, info, length);
}
}
}
xUnit Tests based on the one supplied in the spec:
using System;
using Xunit;
using App.Security;
using App.ExtensionMethods;
using System.Globalization;
namespace Tests.Tools.Security {
public class TestHKDF {
// All reference tests from Appendix A, Test Vectors: https://www.rfc-editor.org/rfc/rfc5869
// ikm = input key material
// salt = a non-secret random value
// info = context and application specific info
// prk = psuedorandom key
// okm = output key matrial
[Fact]
public void GetBytes_TestCase1() {
//Arrange
byte[] ikm = StrToBytes("0x0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
byte[] salt = StrToBytes("0x000102030405060708090a0b0c");
byte[] info = StrToBytes("0xf0f1f2f3f4f5f6f7f8f9");
int L = 42;
//Act
byte[] prk = HKDF.Extract(salt, ikm);
byte[] okm = HKDF.Expand(prk, info, L);
//Assert
byte[] refPrk = StrToBytes("0x077709362c2e32df0ddc3f0dc47bba63" +
"90b6c73bb50f9c3122ec844ad7c2b3e5");
byte[] refOkm = StrToBytes("0x3cb25f25faacd57a90434f64d0362f2a" +
"2d2d0a90cf1a5a4c5db02d56ecc4c5bf" +
"34007208d5b887185865");
Assert.True(Compare(refPrk, prk));
Assert.True(Compare(refOkm, okm));
}
[Fact]
public void GetBytes_TestCase2() {
//Arrange
byte[] ikm = StrToBytes("0x000102030405060708090a0b0c0d0e0f" +
"101112131415161718191a1b1c1d1e1f" +
"202122232425262728292a2b2c2d2e2f" +
"303132333435363738393a3b3c3d3e3f" +
"404142434445464748494a4b4c4d4e4f");
byte[] salt = StrToBytes("0x606162636465666768696a6b6c6d6e6f" +
"707172737475767778797a7b7c7d7e7f" +
"808182838485868788898a8b8c8d8e8f" +
"909192939495969798999a9b9c9d9e9f" +
"a0a1a2a3a4a5a6a7a8a9aaabacadaeaf");
byte[] info = StrToBytes("0xb0b1b2b3b4b5b6b7b8b9babbbcbdbebf" +
"c0c1c2c3c4c5c6c7c8c9cacbcccdcecf" +
"d0d1d2d3d4d5d6d7d8d9dadbdcdddedf" +
"e0e1e2e3e4e5e6e7e8e9eaebecedeeef" +
"f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff");
int L = 82;
//Act
byte[] prk = HKDF.Extract(salt, ikm);
byte[] okm = HKDF.Expand(prk, info, L);
//Assert
byte[] refPrk = StrToBytes("0x06a6b88c5853361a06104c9ceb35b45c"+
"ef760014904671014a193f40c15fc244");
byte[] refOkm = StrToBytes("0xb11e398dc80327a1c8e7f78c596a4934" +
"4f012eda2d4efad8a050cc4c19afa97c" +
"59045a99cac7827271cb41c65e590e09" +
"da3275600c2f09b8367793a9aca3db71" +
"cc30c58179ec3e87c14c01d5c1f3434f" +
"1d87");
Assert.True(Compare(refPrk, prk));
Assert.True(Compare(refOkm, okm));
}
[Fact]
public void GetBytes_TestCase3() {
//Arrange
byte[] ikm = StrToBytes("0x0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
byte[] salt = new byte[0];
byte[] info = new byte[0];
int L = 42;
//Act
byte[] prk = HKDF.Extract(salt, ikm);
byte[] okm = HKDF.Expand(prk, info, L);
//Assert
byte[] refPrk = StrToBytes("0x19ef24a32c717b167f33a91d6f648bdf" +
"96596776afdb6377ac434c1c293ccb04");
byte[] refOkm = StrToBytes("0x8da4e775a563c18f715f802a063c5a31"+
"b8a11f5c5ee1879ec3454e5f3c738d2d" +
"9d201395faa4b61a96c8");
Assert.True(Compare(refPrk, prk));
Assert.True(Compare(refOkm, okm));
}
/// <summary>
/// Converts a hex string to it's byte array equivalent.
/// </summary>
/// <param name="text">example format: 0xf0f1f2f3f4f5f6f7f8f9</param>
/// <returns></returns>
byte[] StrToBytes(string text) {
if(text.Length % 2 != 0)
throw new ArgumentException("Text length must be an even number.");
text = text.RemoveStart("0x");
byte[] bytes = new byte[text.Length / 2];
int i = 0; //bytes index
while((2 * i) + 1 < text.Length) {
bytes[i] = byte.Parse(text.Substring(2*i, 2), NumberStyles.HexNumber); ;
i += 1;
}
return bytes;
}
/// <summary>
/// Returns true if the two byte arrays have the same values.
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <returns></returns>
public static bool Compare(byte[] a, byte[] b) {
if(a.Length != b.Length)
return false;
for(int i = 0; i < a.Length; i++) {
if(a[i] != b[i])
return false;
}
return true;
}
}
}