From ff89d0a7a34b2a4706f2bee7fe8c9ab380983b44 Mon Sep 17 00:00:00 2001 From: Pete Bentley <44170157+prbprbprb@users.noreply.github.com> Date: Thu, 7 Dec 2023 11:10:00 +0000 Subject: [PATCH] Add a public HKDF API, per RFC 5869. (#1163) * Add a public HKDF API, per RFC 5869. Implemented purely from the RFC, no other code reference used. Unit tests test against all available providers to ensure consistency. Creates new Mac instances for each expand/extract operation so probably thread-safe. Probably does too much array copying but we can fix that later if it's an issue. --- common/src/main/java/org/conscrypt/Hkdf.java | 124 ++++++++++++++++++ .../src/test/java/org/conscrypt/HkdfTest.java | 96 ++++++++++++++ common/src/test/resources/crypto/hkdf.txt | 63 +++++++++ .../main/java/org/conscrypt/TestUtils.java | 50 ++++++- .../main/java/org/conscrypt/TestVector.java | 54 ++++++++ 5 files changed, 380 insertions(+), 7 deletions(-) create mode 100644 common/src/main/java/org/conscrypt/Hkdf.java create mode 100644 common/src/test/java/org/conscrypt/HkdfTest.java create mode 100644 common/src/test/resources/crypto/hkdf.txt create mode 100644 testing/src/main/java/org/conscrypt/TestVector.java diff --git a/common/src/main/java/org/conscrypt/Hkdf.java b/common/src/main/java/org/conscrypt/Hkdf.java new file mode 100644 index 000000000..e18b36c0b --- /dev/null +++ b/common/src/main/java/org/conscrypt/Hkdf.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package org.conscrypt; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Objects; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Hkdf - perform HKDF key derivation operations per RFC 5869. + *

+ * Instances should be instantiated using the standard JCA name for the required HMAC. + *

+ * Each invocation of expand or extract uses a new Mac instance and so instances + * of Hkdf are thread-safe. + */ +public final class Hkdf { + // HMAC algorithm to use. + private final String hmacName; + private final int macLength; + + /** + * Creates an Hkdf instance which will use hmacName as the name for the underlying + * HMAC algorithm, which will be located using normal JCA precedence rules. + *

+ * @param hmacName the name of the HMAC algorithm to use + * @throws NoSuchAlgorithmException if hmacName is not a valid HMAC name + */ + public Hkdf(String hmacName) throws NoSuchAlgorithmException { + Objects.requireNonNull(hmacName); + this.hmacName = hmacName; + + // Stash the MAC length with the bonus that we'll fail fast here if no such algorithm. + macLength = Mac.getInstance(hmacName).getMacLength(); + } + + // Visible for testing. + int getMacLength() { + return macLength; + } + + /** + * Performs an HKDF extract operation as specified in RFC 5869. + * + * @param salt the salt to use + * @param ikm initial keying material + * @return a pseudorandom key suitable for use in expand operations + * @throws InvalidKeyException if the salt is not suitable for use as an HMAC key + * @throws NoSuchAlgorithmException if the Mac algorithm is no longer available + */ + + public byte[] extract(byte[] salt, byte[] ikm) + throws InvalidKeyException, NoSuchAlgorithmException { + Objects.requireNonNull(salt); + Objects.requireNonNull(ikm); + Preconditions.checkArgument(ikm.length > 0, "Empty keying material"); + if (salt.length == 0) { + salt = new byte[getMacLength()]; + } + return getMac(salt).doFinal(ikm); + } + + /** + * Performs an HKDF expand operation as specified in RFC 5869. + * + * @param prk a pseudorandom key of at least HashLen octets, usually the output from the + * extract step. Where HashLen is the key size of the underlying Mac + * @param info optional context and application specific information, can be zero length + * @param length length of output keying material in bytes (<= 255*HashLen) + * @return output of keying material of length bytes + * @throws InvalidKeyException if prk is not suitable for use as an HMAC key + * @throws IllegalArgumentException if length is out of the allowed range + * @throws NoSuchAlgorithmException if the Mac algorithm is no longer available + */ + public byte[] expand(byte[] prk, byte[] info, int length) + throws InvalidKeyException, NoSuchAlgorithmException { + Objects.requireNonNull(prk); + Objects.requireNonNull(info); + Preconditions.checkArgument(length >= 0, "Negative length"); + Preconditions.checkArgument(length < 255 * getMacLength(), "Length too long"); + Mac mac = getMac(prk); + int macLength = getMacLength(); + + byte[] t = new byte[0]; + byte[] output = new byte[length]; + int outputOffset = 0; + byte[] counter = new byte[] { 0x00 }; + while (outputOffset < length) { + counter[0]++; + mac.update(t); + mac.update(info); + t = mac.doFinal(counter); + int size = Math.min(macLength, length - outputOffset); + System.arraycopy(t, 0, output, outputOffset, size); + outputOffset += size; + } + return output; + } + + private Mac getMac(byte[] key) throws InvalidKeyException, NoSuchAlgorithmException { + // Can potentially throw NoSuchAlgorithmException if the there has been a change + // in installed Providers. + Mac mac = Mac.getInstance(hmacName); + mac.init(new SecretKeySpec(key, "RAW")); + return mac; // https://www.youtube.com/watch?v=uB1D9wWxd2w + } +} diff --git a/common/src/test/java/org/conscrypt/HkdfTest.java b/common/src/test/java/org/conscrypt/HkdfTest.java new file mode 100644 index 000000000..3acbdce2e --- /dev/null +++ b/common/src/test/java/org/conscrypt/HkdfTest.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package org.conscrypt; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import java.security.NoSuchAlgorithmException; +import java.util.List; + +import javax.crypto.Mac; + +@RunWith(JUnit4.class) +public class HkdfTest { + private final String SHA256 = "HmacSHA256"; + + @Test + public void constructor() throws Exception { + assertThrows(NullPointerException.class, () -> new Hkdf(null)); + assertThrows(NoSuchAlgorithmException.class, () -> new Hkdf("No such MAC")); + + Hkdf hkdf = new Hkdf(SHA256); + assertEquals(Mac.getInstance(SHA256).getMacLength(), hkdf.getMacLength()); + } + + @Test + public void extract() throws Exception { + Hkdf hkdf = new Hkdf(SHA256); + assertThrows(NullPointerException.class, () -> hkdf.extract(null, new byte[0])); + assertThrows(NullPointerException.class, () -> hkdf.extract(new byte[0], null)); + assertThrows(NullPointerException.class, () -> hkdf.extract(null, null)); + assertThrows(IllegalArgumentException.class, () -> hkdf.extract(new byte[0], new byte[0])); + } + + @Test + public void expand() throws Exception { + Hkdf hkdf = new Hkdf(SHA256); + int macLen = hkdf.getMacLength(); + assertThrows(NullPointerException.class, () -> hkdf.expand(null, new byte[0], 1)); + assertThrows(NullPointerException.class, () -> hkdf.expand(new byte[macLen], null, 1)); + assertThrows(NullPointerException.class, () -> hkdf.expand(null, null, 1)); + assertThrows(NullPointerException.class, () -> hkdf.expand(null, null, 1)); + // Negative length + assertThrows(IllegalArgumentException.class, + () -> hkdf.expand(new byte[macLen], new byte[0], -1)); + // PRK too small + assertThrows(IllegalArgumentException.class, + () -> hkdf.expand(new byte[0], new byte[0], 1)); + // Length too large + assertThrows(IllegalArgumentException.class, + () -> hkdf.expand(new byte[macLen], new byte[0], 255 * macLen + 1)); + } + + @Test + public void testVectors() throws Exception { + List vectors = TestUtils.readTestVectors("crypto/hkdf.txt"); + + for (TestVector vector : vectors) { + String errMsg = vector.getString("name"); + String macName = vector.getString("hash"); + byte[] ikm = vector.getBytes("ikm"); + byte[] salt = vector.getBytesOrEmpty("salt"); + byte[] prk_expected = vector.getBytes("prk"); + + Hkdf hkdf = new Hkdf(macName); + byte[] prk = hkdf.extract(salt, ikm); + assertArrayEquals(errMsg, prk_expected, prk); + + byte[] info = vector.getBytes("info"); + int length = vector.getInt("l"); + byte[] okm_expected = vector.getBytes("okm"); + + byte[] okm = hkdf.expand(prk, info, length); + assertArrayEquals(errMsg, okm_expected, okm); + } + } +} diff --git a/common/src/test/resources/crypto/hkdf.txt b/common/src/test/resources/crypto/hkdf.txt new file mode 100644 index 000000000..98acbb6b7 --- /dev/null +++ b/common/src/test/resources/crypto/hkdf.txt @@ -0,0 +1,63 @@ +# Test vectors from RFC 5869 + +Name = Basic test case with SHA-256 +Hash = HmacSHA256 +IKM = 0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b +salt = 000102030405060708090a0b0c +info = f0f1f2f3f4f5f6f7f8f9 +L = 42 +PRK = 077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5 +OKM = 3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865 + +Name = Test with SHA-256 and longer inputs/outputs +Hash = HmacSHA256 +IKM = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f +salt = 606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeaf +info = b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff +L = 82 +PRK = 06a6b88c5853361a06104c9ceb35b45cef760014904671014a193f40c15fc244 +OKM = b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c59045a99cac7827271cb41c65e590e09da3275600c2f09b8367793a9aca3db71cc30c58179ec3e87c14c01d5c1f3434f1d87 + +Name = Test with SHA-256 and zero-length salt/info +Hash = HmacSHA256 +IKM = 0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b +salt = +info = +L = 42 +PRK = 19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04 +OKM = 8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8 + +Name = Basic test case with SHA-1 +Hash = HmacSHA1 +IKM = 0b0b0b0b0b0b0b0b0b0b0b +salt = 000102030405060708090a0b0c +info = f0f1f2f3f4f5f6f7f8f9 +L = 42 +PRK = 9b6c18c432a7bf8f0e71c8eb88f4b30baa2ba243 +OKM = 085a01ea1b10f36933068b56efa5ad81a4f14b822f5b091568a9cdd4f155fda2c22e422478d305f3f896 + +Name = Test with SHA-1 and longer inputs/outputs +Hash = HmacSHA1 +IKM = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f +salt = 606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeaf +info = b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff +L = 82 +PRK = 8adae09a2a307059478d309b26c4115a224cfaf6 +OKM = 0bd770a74d1160f7c9f12cd5912a06ebff6adcae899d92191fe4305673ba2ffe8fa3f1a4e5ad79f3f334b3b202b2173c486ea37ce3d397ed034c7f9dfeb15c5e927336d0441f4c4300e2cff0d0900b52d3b4 + +Name = Test with SHA-1 and zero-length salt/info +Hash = HmacSHA1 +IKM = 0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b +salt = +info = +L = 42 +PRK = da8c8a73c7fa77288ec6f5e7c297786aa0d32d01 +OKM = 0ac1af7002b3d761d1e55298da9d0506b9ae52057220a306e07b6b87e8df21d0ea00033de03984d34918 + +Name = Test with SHA-1, salt not provided (defaults to HashLen zero octets), zero-length info +Hash = HmacSHA1 +IKM = 0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c +info = +L = 42 +PRK = 2adccada18779e7c2077ad2eb19d3f3e731385dd +OKM = 2c91117204d745f3500d636a62f64f0ab3bae548aa53d423b0d1f27ebba6f5e5673a081d70cce7acfc48 diff --git a/testing/src/main/java/org/conscrypt/TestUtils.java b/testing/src/main/java/org/conscrypt/TestUtils.java index 8ba6d34a2..42bd50598 100644 --- a/testing/src/main/java/org/conscrypt/TestUtils.java +++ b/testing/src/main/java/org/conscrypt/TestUtils.java @@ -123,18 +123,22 @@ private TestUtils() {} private static Provider getNonConscryptTlsProvider() { for (String protocol : DESIRED_JDK_PROTOCOLS) { - for (Provider p : Security.getProviders()) { - if (!p.getClass().getPackage().getName().contains("conscrypt") - && hasSslContext(p, protocol)) { - return p; - } + Provider p = getNonConscryptProviderFor("SSLContext", protocol); + if (p != null) { + return p; } } return new BouncyCastleProvider(); } - private static boolean hasSslContext(Provider p, String protocol) { - return p.get("SSLContext." + protocol) != null; + static Provider getNonConscryptProviderFor(String type, String algorithm) { + for (Provider p : Security.getProviders()) { + if (!p.getClass().getPackage().getName().contains("conscrypt") + && (p.getService(type, algorithm) != null)) { + return p; + } + } + return null; } static Provider getJdkProvider() { @@ -294,6 +298,38 @@ public static List readCsvResource(String resourceName) throws IOExcep return lines; } + public static List readTestVectors(String resourceName) throws IOException { + InputStream stream = openTestFile(resourceName); + List result = new ArrayList<>(); + TestVector current = null; + try (BufferedReader reader + = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + int lineNumber = 0; + while ((line = reader.readLine()) != null) { + lineNumber++; + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + int index = line.indexOf('='); + if (index < 0) { + throw new IllegalStateException("No = found: line " + lineNumber); + } + String label = line.substring(0, index).trim().toLowerCase(); + String value = line.substring(index + 1).trim(); + if ("name".equals(label)) { + current = new TestVector(); + result.add(current); + } else if (current == null) { + throw new IllegalStateException("Vectors must start with a name: line " + + lineNumber); + } + current.put(label, value); + } + } + return result; + } + /** * Looks up the conscrypt class for the given simple name (i.e. no package prefix). */ diff --git a/testing/src/main/java/org/conscrypt/TestVector.java b/testing/src/main/java/org/conscrypt/TestVector.java new file mode 100644 index 000000000..fc8b15697 --- /dev/null +++ b/testing/src/main/java/org/conscrypt/TestVector.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.conscrypt; + +import static org.conscrypt.TestUtils.decodeHex; + +import java.util.HashMap; +import java.util.Map; + +public final class TestVector { + private final Map map = new HashMap<>(); + + public void put(String label, String value) { + map.put(label, value); + } + public String getString(String label) { + return map.get(label); + } + + public byte[] getBytes(String label) { + return decodeHex(getString(label)); + } + + public byte[] getBytesOrEmpty(String label) { + return contains(label) ? getBytes(label) : new byte[0]; + } + + public int getInt(String label) { + return Integer.parseInt(getString(label)); + } + + public boolean contains(String label) { + return map.containsKey(label); + } + + @Override + public String toString() { + return map.toString(); + } +}