7
\$\begingroup\$

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;
        }
    }
}
\$\endgroup\$

2 Answers 2

4
\$\begingroup\$

Just a note but I think you have the parameters backwards in GetBytes:

byte[] key = Extract(salt, inputKeyMaterial);

Based on your implementation of:

byte[] Extract(byte[] inputKeyMaterial, byte[] salt)
\$\endgroup\$
0
2
\$\begingroup\$

Disclaimer: Only reviewing practice and style (not the algorithm)

Validation

  • Expand()

    Because the method is public you should validate the methods parameter some more

    The validation of the byte[] key should be done in two steps. Throwing an ArgumentNullException for the case key == null and throwing the ArgumentOutOfRangeException if key.Size < 32.

    The validation of the length parameter should also check for cases where length < 0.

  • Extract()

    Because the method is public you should validate the methods parameter at least against null.

  • GetBytes()

    Because the method is public you should validate the methods parameter at least against null.


You should declare your variables as near as possible to their usage.

In Expand() you should move byte[] buffer; and int bytesToCopy inside the while(outputIndex < length)

\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.