Skip to content

Commit

Permalink
Add a public HKDF API, per RFC 5869. (#1163)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
prbprbprb authored Dec 7, 2023
1 parent a535853 commit ff89d0a
Show file tree
Hide file tree
Showing 5 changed files with 380 additions and 7 deletions.
124 changes: 124 additions & 0 deletions common/src/main/java/org/conscrypt/Hkdf.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* Instances should be instantiated using the standard JCA name for the required HMAC.
* <p>
* 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.
* <p>
* @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
}
}
96 changes: 96 additions & 0 deletions common/src/test/java/org/conscrypt/HkdfTest.java
Original file line number Diff line number Diff line change
@@ -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<TestVector> 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);
}
}
}
63 changes: 63 additions & 0 deletions common/src/test/resources/crypto/hkdf.txt
Original file line number Diff line number Diff line change
@@ -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
50 changes: 43 additions & 7 deletions testing/src/main/java/org/conscrypt/TestUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -294,6 +298,38 @@ public static List<String[]> readCsvResource(String resourceName) throws IOExcep
return lines;
}

public static List<TestVector> readTestVectors(String resourceName) throws IOException {
InputStream stream = openTestFile(resourceName);
List<TestVector> 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).
*/
Expand Down
Loading

0 comments on commit ff89d0a

Please sign in to comment.