diff options
Diffstat (limited to 'security-utils/src/test/java/com/yahoo/security')
3 files changed, 472 insertions, 0 deletions
diff --git a/security-utils/src/test/java/com/yahoo/security/HKDFTest.java b/security-utils/src/test/java/com/yahoo/security/HKDFTest.java new file mode 100644 index 00000000000..bf000cbf8d2 --- /dev/null +++ b/security-utils/src/test/java/com/yahoo/security/HKDFTest.java @@ -0,0 +1,298 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.security; + +import org.bouncycastle.util.encoders.Hex; +import org.junit.jupiter.api.Test; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * HKDF tests that ensure that the output of our own implementation matches the test + * vectors given in <a href="https://tools.ietf.org/html/rfc5869">RFC-5869</a>. + * + * We don't expose the internal PRK (pseudo-random key) value of the HKDF itself, + * so we don't test it explicitly. The actual OKM (output keying material) inherently + * depends on it, so its correctness is verified transitively. + * + * @author vekterli + */ +public class HKDFTest { + + private static byte[] fromHex(String hex) { + return Hex.decode(hex); + } + + private static String toHex(byte[] bytes) { + return Hex.toHexString(bytes); + } + + private static byte[] sha256DigestOf(byte[]... buffers) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (byte[] buf : buffers) { + digest.update(buf); + } + return digest.digest(); + } catch (NoSuchAlgorithmException e) { + // SHA-256 should always be present, so this should never be reached in practice + throw new RuntimeException(e); + } + } + + /* + A.1. Test Case 1 + + Basic test case with SHA-256 + + Hash = SHA-256 + IKM = 0x0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b (22 octets) + salt = 0x000102030405060708090a0b0c (13 octets) + info = 0xf0f1f2f3f4f5f6f7f8f9 (10 octets) + L = 42 + + PRK = 0x077709362c2e32df0ddc3f0dc47bba63 + 90b6c73bb50f9c3122ec844ad7c2b3e5 (32 octets) + OKM = 0x3cb25f25faacd57a90434f64d0362f2a + 2d2d0a90cf1a5a4c5db02d56ecc4c5bf + 34007208d5b887185865 (42 octets) + */ + @Test + void rfc_5869_test_vector_case_1() { + var ikm = fromHex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"); + var salt = fromHex("000102030405060708090a0b0c"); + var info = fromHex("f0f1f2f3f4f5f6f7f8f9"); + + var hkdf = HKDF.extractedFrom(salt, ikm); + var okm = hkdf.expand(42, info); + assertEquals("3cb25f25faacd57a90434f64d0362f2a" + + "2d2d0a90cf1a5a4c5db02d56ecc4c5bf" + + "34007208d5b887185865", + toHex(okm)); + } + + @Test + void rfc_5869_test_vector_case_1_block_boundary_edge_cases() { + var ikm = fromHex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"); + var salt = fromHex("000102030405060708090a0b0c"); + var info = fromHex("f0f1f2f3f4f5f6f7f8f9"); + + var hkdf = HKDF.extractedFrom(salt, ikm); + var okm = hkdf.expand(31, info); // One less than block size + assertEquals("3cb25f25faacd57a90434f64d0362f2a" + + "2d2d0a90cf1a5a4c5db02d56ecc4c5", + toHex(okm)); + + okm = hkdf.expand(32, info); // Exactly equal to block size + assertEquals("3cb25f25faacd57a90434f64d0362f2a" + + "2d2d0a90cf1a5a4c5db02d56ecc4c5bf", + toHex(okm)); + + okm = hkdf.expand(33, info); // One more than block size + assertEquals("3cb25f25faacd57a90434f64d0362f2a" + + "2d2d0a90cf1a5a4c5db02d56ecc4c5bf" + + "34", + toHex(okm)); + } + + /* + A.2. Test Case 2 + + Test with SHA-256 and longer inputs/outputs + + Hash = SHA-256 + IKM = 0x000102030405060708090a0b0c0d0e0f + 101112131415161718191a1b1c1d1e1f + 202122232425262728292a2b2c2d2e2f + 303132333435363738393a3b3c3d3e3f + 404142434445464748494a4b4c4d4e4f (80 octets) + salt = 0x606162636465666768696a6b6c6d6e6f + 707172737475767778797a7b7c7d7e7f + 808182838485868788898a8b8c8d8e8f + 909192939495969798999a9b9c9d9e9f + a0a1a2a3a4a5a6a7a8a9aaabacadaeaf (80 octets) + info = 0xb0b1b2b3b4b5b6b7b8b9babbbcbdbebf + c0c1c2c3c4c5c6c7c8c9cacbcccdcecf + d0d1d2d3d4d5d6d7d8d9dadbdcdddedf + e0e1e2e3e4e5e6e7e8e9eaebecedeeef + f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff (80 octets) + L = 82 + + PRK = 0x06a6b88c5853361a06104c9ceb35b45c + ef760014904671014a193f40c15fc244 (32 octets) + OKM = 0xb11e398dc80327a1c8e7f78c596a4934 + 4f012eda2d4efad8a050cc4c19afa97c + 59045a99cac7827271cb41c65e590e09 + da3275600c2f09b8367793a9aca3db71 + cc30c58179ec3e87c14c01d5c1f3434f + 1d87 (82 octets) + */ + @Test + void rfc_5869_test_vector_case_2() { + var ikm = fromHex("000102030405060708090a0b0c0d0e0f" + + "101112131415161718191a1b1c1d1e1f" + + "202122232425262728292a2b2c2d2e2f" + + "303132333435363738393a3b3c3d3e3f" + + "404142434445464748494a4b4c4d4e4f"); + var salt = fromHex("606162636465666768696a6b6c6d6e6f" + + "707172737475767778797a7b7c7d7e7f" + + "808182838485868788898a8b8c8d8e8f" + + "909192939495969798999a9b9c9d9e9f" + + "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf"); + var info = fromHex("b0b1b2b3b4b5b6b7b8b9babbbcbdbebf" + + "c0c1c2c3c4c5c6c7c8c9cacbcccdcecf" + + "d0d1d2d3d4d5d6d7d8d9dadbdcdddedf" + + "e0e1e2e3e4e5e6e7e8e9eaebecedeeef" + + "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"); + + var hkdf = HKDF.extractedFrom(salt, ikm); + var okm = hkdf.expand(82, info); + assertEquals("b11e398dc80327a1c8e7f78c596a4934" + + "4f012eda2d4efad8a050cc4c19afa97c" + + "59045a99cac7827271cb41c65e590e09" + + "da3275600c2f09b8367793a9aca3db71" + + "cc30c58179ec3e87c14c01d5c1f3434f" + + "1d87", + toHex(okm)); + } + + /* + A.3. Test Case 3 + + Test with SHA-256 and zero-length salt/info + + Hash = SHA-256 + IKM = 0x0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b (22 octets) + salt = (0 octets) + info = (0 octets) + L = 42 + + PRK = 0x19ef24a32c717b167f33a91d6f648bdf + 96596776afdb6377ac434c1c293ccb04 (32 octets) + OKM = 0x8da4e775a563c18f715f802a063c5a31 + b8a11f5c5ee1879ec3454e5f3c738d2d + 9d201395faa4b61a96c8 (42 octets) + */ + @Test + void rfc_5869_test_vector_case_3() { + var ikm = fromHex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"); + var info = new byte[0]; + + // We don't allow empty salt to the salted factory function, so this is equivalent. + var hkdf = HKDF.unsaltedExtractedFrom(ikm); + var okm = hkdf.expand(42, info); + var expectedOkm = "8da4e775a563c18f715f802a063c5a31" + + "b8a11f5c5ee1879ec3454e5f3c738d2d" + + "9d201395faa4b61a96c8"; + assertEquals(expectedOkm, toHex(okm)); + + // expand() without explicit context should return as if an empty context array was passed + okm = hkdf.expand(42); + assertEquals(expectedOkm, toHex(okm)); + } + + @Test + void requested_key_size_is_bounded_and_checked() { + var ikm = fromHex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"); + var salt = fromHex("000102030405060708090a0b0c"); + var hkdf = HKDF.extractedFrom(salt, ikm); + + assertThrows(IllegalArgumentException.class, () -> hkdf.expand(-1)); // Can't request negative output size + + assertThrows(IllegalArgumentException.class, () -> hkdf.expand(0)); // Need at least 1 key byte + + assertThrows(IllegalArgumentException.class, () -> hkdf.expand(HKDF.MAX_OUTPUT_SIZE + 1)); // 1 too large + } + + @Test + void missing_salt_to_salted_factory_function_throws_exception() { + var ikm = fromHex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"); + assertThrows(NullPointerException.class, () -> HKDF.extractedFrom(null, ikm)); + assertThrows(IllegalArgumentException.class, () -> HKDF.extractedFrom(new byte[0], ikm)); + } + + @Test + void ikm_can_not_be_null_or_empty() { + var salt = fromHex("000102030405060708090a0b0c"); + assertThrows(NullPointerException.class, () -> HKDF.extractedFrom(salt, null)); + assertThrows(IllegalArgumentException.class, () -> HKDF.extractedFrom(salt, new byte[0])); + assertThrows(NullPointerException.class, () -> HKDF.unsaltedExtractedFrom(null)); + assertThrows(IllegalArgumentException.class, () -> HKDF.unsaltedExtractedFrom(new byte[0])); + } + + // + // Subset of Wycheproof test vectors for specific named edge cases + // From https://github.com/google/wycheproof/blob/master/testvectors/hkdf_sha256_test.json + // + + @Test + void maximal_output_size() { + var ikm = fromHex("bdd9c30b5fab7f22d859db774779b41cc124daf3ce872f6e80951c0edd8f8214"); + var salt = fromHex("90983ed74912c6173d0f7cf8164b525361b89bda04d085341a057bde9083b5af"); + var info = fromHex("e6483e923d37e4ba"); + + var hkdf = HKDF.extractedFrom(salt, ikm); + assertEquals(8160, HKDF.MAX_OUTPUT_SIZE); + var okm = hkdf.expand(HKDF.MAX_OUTPUT_SIZE, info); + // To avoid shoving an 8K sized hex string into the source code, check against the pre-hashed + // value of the expected OKM output. It's hashes all the way down! + var expectedOkmSha256Digest = "c17ce0403e133570191dd1d2ca46f6b62623d62e4f0def8de23a51d65d40a009"; + var okmDigest = sha256DigestOf(okm); + assertEquals(expectedOkmSha256Digest, toHex(okmDigest)); + } + + @Test + void output_collision_for_different_salts() { + var ikm = fromHex("5943c65bc33bf05a205b04be8ae0ab2e"); + var info = fromHex("be082f301a03f87787a80fbea88941214d50c42b"); + var hkdf = HKDF.unsaltedExtractedFrom(ikm); + + var okm = hkdf.expand(32, info); + var expectedOkm = "e7f384df2eae32addabd068a758dec84ed7fcfd87a5fcceb37b70c51422d7387"; + assertEquals(expectedOkm, toHex(okm)); + + var salt = fromHex("0000000000000000000000000000000000000000000000000000000000000000"); + hkdf = HKDF.extractedFrom(salt, ikm); + okm = hkdf.expand(32, info); + assertEquals(expectedOkm, toHex(okm)); + } + + @Test + void salt_longer_than_block_size_is_equivalent_to_hash_of_the_salt() { + var ikm = fromHex("624a5b59c2be55cbe29ea90c0020a7e8c60f2501"); + var info = fromHex("5447e595250d02165aae3e61fa90313e25509a7b"); + var salts = List.of("c737d7278df1ec7c0a549ce964abd51c3df1d3584d49e77208cd3f9f5bbfb32e", + "1a08959149f4b073bcd902c9bc4ed0324c21c95590773afc77037d610b9584806aeeeda8b5" + + "d588d0cd79e7c12211b8e394067516ce12946d61111a52042b539353"); + var expectedOkm = "d45c3909269f4b5f9de1fb2eeb0593a7cb9175c8835aba37e0ee0c4cb3bd87c4"; + for (var salt : salts) { + var hkdf = HKDF.extractedFrom(fromHex(salt), ikm); + var okm = hkdf.expand(32, info); + assertEquals(expectedOkm, toHex(okm)); + } + } + + @Test + void salt_shorter_than_the_block_size_is_padded_with_zeros() { + var ikm = fromHex("5943c65bc33bf05a205b04be8ae0ab2e"); + var info = fromHex("be082f301a03f87787a80fbea88941214d50c42b"); + var expectedOkm = "43e371354001617abb70454751059625ef1a64e0f818469c2f886b27140a0166"; + var salts = List.of("e69dcaad55fb0536", + "e69dcaad55fb05360000000000000000", + "e69dcaad55fb053600000000000000000000000000000000", + "e69dcaad55fb0536000000000000000000000000000000000000000000000000", + "e69dcaad55fb05360000000000000000000000000000000000000000000000000000000000000000", + "e69dcaad55fb053600000000000000000000000000000000000000000000000000000000000000000000000000000000", + "e69dcaad55fb0536000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"); + for (var salt : salts) { + var hkdf = HKDF.extractedFrom(fromHex(salt), ikm); + var okm = hkdf.expand(32, info); + assertEquals(expectedOkm, toHex(okm), "Failed for salt %s".formatted(salt)); + } + } + +} diff --git a/security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java b/security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java new file mode 100644 index 00000000000..26a506015c5 --- /dev/null +++ b/security-utils/src/test/java/com/yahoo/security/SharedKeyTest.java @@ -0,0 +1,136 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.security; + +import org.bouncycastle.util.encoders.Hex; +import org.junit.jupiter.api.Test; + +import javax.crypto.CipherInputStream; +import javax.crypto.CipherOutputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class SharedKeyTest { + + @Test + void generated_secret_key_is_256_bit_aes() { + var receiverKeyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC); + var shared = SharedKeyGenerator.generateForReceiverPublicKey(receiverKeyPair.getPublic(), 1); + var secret = shared.secretKey(); + assertEquals(secret.getAlgorithm(), "AES"); + assertEquals(secret.getEncoded().length, 32); + } + + @Test + void sealed_shared_key_can_be_exchanged_via_token_and_computes_identical_secret_key_at_receiver() { + var receiverKeyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC); + + var myShared = SharedKeyGenerator.generateForReceiverPublicKey(receiverKeyPair.getPublic(), 1); + var publicToken = myShared.sealedSharedKey().toTokenString(); + + var theirSealed = SealedSharedKey.fromTokenString(publicToken); + var theirShared = SharedKeyGenerator.fromSealedKey(theirSealed, receiverKeyPair.getPrivate()); + + assertArrayEquals(myShared.secretKey().getEncoded(), theirShared.secretKey().getEncoded()); + } + + @Test + void token_v1_representation_is_stable() { + var receiverPrivate = KeyUtils.fromPemEncodedPrivateKey( + """ + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIO+CkAccoU9jPjX64mwU54Ar9DNZSLBBTYRSINerSW8EoAoGCCqGSM49 + AwEHoUQDQgAE3FA2VSuOn0vVhtQgNe13H2UE0Vx5A41demyX8nkHTCO4BDXSEPca + vejY7YaVcNSvFUbzDvia51X4pxbr1pe56g== + -----END EC PRIVATE KEY----- + """); + var receiverPublic = KeyUtils.fromPemEncodedPublicKey( + """ + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE3FA2VSuOn0vVhtQgNe13H2UE0Vx5 + A41demyX8nkHTCO4BDXSEPcavejY7YaVcNSvFUbzDvia51X4pxbr1pe56g== + -----END PUBLIC KEY----- + """ + ); + var receiverKeyPair = new KeyPair(receiverPublic, receiverPrivate); + + // Token generated for the above receiver public key, with the below expected shared secret (in hex) + var publicToken = "AQAAAUfvuJpugUV3knQXwyP7afgEpDXT4JxaF-x7Ykirty2iwUqJv5UsGx78is5Vu4Mdln_mOVbAUv4dj" + + "da7hvzKYNC3IpSMjFrTQ8ab-bEkMpc5tjss_Z7DaJzY4fUlw31Lhx39BMB5yQX0pVLMdFGp5F-_8z8CE" + + "-7d9lkCDP9hPKiD77besjrBt_mEBadCd4oNONqc6zzhuQj4O5T9k_RC5VRV"; + var expectedSharedSecret = "64e01295e736cb827e86cf0281385d5a0dcca217ec1b59f6609a06e2e9debf78"; + + var theirSealed = SealedSharedKey.fromTokenString(publicToken); + var theirShared = SharedKeyGenerator.fromSealedKey(theirSealed, receiverKeyPair.getPrivate()); + + assertEquals(expectedSharedSecret, Hex.toHexString(theirShared.secretKey().getEncoded())); + } + + @Test + void unrelated_private_key_cannot_decrypt_shared_secret_key() { + var aliceKeyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC); + var eveKeyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC); + var bobShared = SharedKeyGenerator.generateForReceiverPublicKey(aliceKeyPair.getPublic(), 1); + assertThrows(IllegalArgumentException.class, // TODO consider distinct exception class + () -> SharedKeyGenerator.fromSealedKey(bobShared.sealedSharedKey(), eveKeyPair.getPrivate())); + } + + @Test + void token_carries_key_id_as_metadata() { + int keyId = 12345; + var keyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC); + var myShared = SharedKeyGenerator.generateForReceiverPublicKey(keyPair.getPublic(), keyId); + var publicToken = myShared.sealedSharedKey().toTokenString(); + var theirShared = SealedSharedKey.fromTokenString(publicToken); + assertEquals(theirShared.keyId(), keyId); + } + + static byte[] streamEncryptString(String data, SecretSharedKey secretSharedKey) throws IOException { + var cipher = SharedKeyGenerator.makeAesGcmEncryptionCipher(secretSharedKey); + var outStream = new ByteArrayOutputStream(); + try (var cipherStream = new CipherOutputStream(outStream, cipher)) { + cipherStream.write(data.getBytes(StandardCharsets.UTF_8)); + cipherStream.flush(); + } + return outStream.toByteArray(); + } + + static String streamDecryptString(byte[] encrypted, SecretSharedKey secretSharedKey) throws IOException { + var cipher = SharedKeyGenerator.makeAesGcmDecryptionCipher(secretSharedKey); + var inStream = new ByteArrayInputStream(encrypted); + var total = ByteBuffer.allocate(encrypted.length); // Assume decrypted form can't be _longer_ + byte[] tmp = new byte[8]; // short buf to test chunking + try (var cipherStream = new CipherInputStream(inStream, cipher)) { + while (true) { + int read = cipherStream.read(tmp); + if (read == -1) { + break; + } + total.put(tmp, 0, read); + } + } + total.flip(); + byte[] strBytes = new byte[total.remaining()]; + total.get(strBytes); + return new String(strBytes, StandardCharsets.UTF_8); + } + + @Test + void can_create_symmetric_ciphers_from_shared_secret_key_and_public_keys() throws Exception { + var receiverKeyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC); + var myShared = SharedKeyGenerator.generateForReceiverPublicKey(receiverKeyPair.getPublic(), 1); + + String terrifyingSecret = "birds are not real D:"; + byte[] encrypted = streamEncryptString(terrifyingSecret, myShared); + String decrypted = streamDecryptString(encrypted, myShared); + assertEquals(terrifyingSecret, decrypted); + } + +} diff --git a/security-utils/src/test/java/com/yahoo/security/SideChannelSafeTest.java b/security-utils/src/test/java/com/yahoo/security/SideChannelSafeTest.java new file mode 100644 index 00000000000..7a66ed6eb7f --- /dev/null +++ b/security-utils/src/test/java/com/yahoo/security/SideChannelSafeTest.java @@ -0,0 +1,38 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.security; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * _Functional_ test of side channel safe utility functions. Testing that they're actually + * (probably) side channel safe would be too flaky since it's inherently timing-dependent. + */ +public class SideChannelSafeTest { + + @Test + void all_zeros_checks_length_and_array_contents() { + assertFalse(SideChannelSafe.allZeros(new byte[0])); + assertFalse(SideChannelSafe.allZeros(new byte[]{ 1 })); + assertTrue(SideChannelSafe.allZeros(new byte[]{ 0 })); + assertFalse(SideChannelSafe.allZeros(new byte[]{ 0, 0, 127, 0 })); + assertFalse(SideChannelSafe.allZeros(new byte[]{ 0, 0, -1, 0 })); + assertTrue(SideChannelSafe.allZeros(new byte[]{ 0, 0, 0 })); + } + + @Test + void arrays_equal_checks_length_and_array_contents() { + assertTrue(SideChannelSafe.arraysEqual(new byte[0], new byte[0])); + assertFalse(SideChannelSafe.arraysEqual(new byte[] { 0 }, new byte[0])); + assertFalse(SideChannelSafe.arraysEqual(new byte[0], new byte[]{ 0 })); + assertTrue(SideChannelSafe.arraysEqual(new byte[] { 0, 0, 0 }, new byte[] { 0, 0, 0 })); + assertTrue(SideChannelSafe.arraysEqual(new byte[] { 0x7, 0xe }, new byte[] { 0x7, 0xe })); + assertFalse(SideChannelSafe.arraysEqual(new byte[] { 0xe, 0x7 }, new byte[] { 0x7, 0xe })); + assertFalse(SideChannelSafe.arraysEqual(new byte[] { -1, 127 }, new byte[] { 127, -1 })); + assertFalse(SideChannelSafe.arraysEqual(new byte[] { -1, -1, 1 }, new byte[] { -1, -1, 2 })); + assertFalse(SideChannelSafe.arraysEqual(new byte[] { 0, -1, 1 }, new byte[] { 0, -1, 3 })); + } + +} |