http://git-wip-us.apache.org/repos/asf/nifi/blob/498b5023/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/AESKeyedCipherProviderGroovyTest.groovy ---------------------------------------------------------------------- diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/AESKeyedCipherProviderGroovyTest.groovy b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/AESKeyedCipherProviderGroovyTest.groovy new file mode 100644 index 0000000..0596d7d --- /dev/null +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/AESKeyedCipherProviderGroovyTest.groovy @@ -0,0 +1,340 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.nifi.processors.standard.util.crypto + +import org.apache.commons.codec.binary.Hex +import org.apache.nifi.security.util.EncryptionMethod +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.junit.* +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec +import java.security.SecureRandom +import java.security.Security + +import static groovy.test.GroovyAssert.shouldFail + +@RunWith(JUnit4.class) +public class AESKeyedCipherProviderGroovyTest { + private static final Logger logger = LoggerFactory.getLogger(AESKeyedCipherProviderGroovyTest.class) + + private static final String KEY_HEX = "0123456789ABCDEFFEDCBA9876543210" + + private static final List<EncryptionMethod> keyedEncryptionMethods = EncryptionMethod.values().findAll { it.keyedCipher } + + private static final SecretKey key = new SecretKeySpec(Hex.decodeHex(KEY_HEX as char[]), "AES") + + @BeforeClass + public static void setUpOnce() throws Exception { + Security.addProvider(new BouncyCastleProvider()) + + logger.metaClass.methodMissing = { String name, args -> + logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}") + } + } + + @Before + public void setUp() throws Exception { + } + + @After + public void tearDown() throws Exception { + } + + @Test + public void testGetCipherShouldBeInternallyConsistent() throws Exception { + // Arrange + KeyedCipherProvider cipherProvider = new AESKeyedCipherProvider() + + final String plaintext = "This is a plaintext message." + + // Act + for (EncryptionMethod em : keyedEncryptionMethods) { + logger.info("Using algorithm: ${em.getAlgorithm()}") + + // Initialize a cipher for encryption + Cipher cipher = cipherProvider.getCipher(em, key, true) + byte[] iv = cipher.getIV() + logger.info("IV: ${Hex.encodeHexString(iv)}") + + byte[] cipherBytes = cipher.doFinal(plaintext.getBytes("UTF-8")) + logger.info("Cipher text: ${Hex.encodeHexString(cipherBytes)} ${cipherBytes.length}") + + cipher = cipherProvider.getCipher(em, key, iv, false) + byte[] recoveredBytes = cipher.doFinal(cipherBytes) + String recovered = new String(recoveredBytes, "UTF-8") + logger.info("Recovered: ${recovered}") + + // Assert + assert plaintext.equals(recovered) + } + } + + @Test + public void testGetCipherWithExternalIVShouldBeInternallyConsistent() throws Exception { + // Arrange + KeyedCipherProvider cipherProvider = new AESKeyedCipherProvider() + + final String plaintext = "This is a plaintext message." + + // Act + keyedEncryptionMethods.each { EncryptionMethod em -> + logger.info("Using algorithm: ${em.getAlgorithm()}") + byte[] iv = cipherProvider.generateIV() + logger.info("IV: ${Hex.encodeHexString(iv)}") + + // Initialize a cipher for encryption + Cipher cipher = cipherProvider.getCipher(em, key, iv, true) + + byte[] cipherBytes = cipher.doFinal(plaintext.getBytes("UTF-8")) + logger.info("Cipher text: ${Hex.encodeHexString(cipherBytes)} ${cipherBytes.length}") + + cipher = cipherProvider.getCipher(em, key, iv, false) + byte[] recoveredBytes = cipher.doFinal(cipherBytes) + String recovered = new String(recoveredBytes, "UTF-8") + logger.info("Recovered: ${recovered}") + + // Assert + assert plaintext.equals(recovered) + } + } + + @Test + public void testGetCipherWithUnlimitedStrengthShouldBeInternallyConsistent() throws Exception { + // Arrange + Assume.assumeTrue("Test is being skipped due to this JVM lacking JCE Unlimited Strength Jurisdiction Policy file.", + PasswordBasedEncryptor.supportsUnlimitedStrength()) + + KeyedCipherProvider cipherProvider = new AESKeyedCipherProvider() + final List<Integer> LONG_KEY_LENGTHS = [192, 256] + + final String plaintext = "This is a plaintext message." + + SecureRandom secureRandom = new SecureRandom() + + // Act + keyedEncryptionMethods.each { EncryptionMethod em -> + // Re-use the same IV for the different length keys to ensure the encryption is different + byte[] iv = cipherProvider.generateIV() + logger.info("IV: ${Hex.encodeHexString(iv)}") + + LONG_KEY_LENGTHS.each { int keyLength -> + logger.info("Using algorithm: ${em.getAlgorithm()} with key length ${keyLength}") + + // Generate a key + byte[] keyBytes = new byte[keyLength / 8] + secureRandom.nextBytes(keyBytes) + SecretKey localKey = new SecretKeySpec(keyBytes, "AES") + logger.info("Key: ${Hex.encodeHexString(keyBytes)} ${keyBytes.length}") + + // Initialize a cipher for encryption + Cipher cipher = cipherProvider.getCipher(em, localKey, iv, true) + + byte[] cipherBytes = cipher.doFinal(plaintext.getBytes("UTF-8")) + logger.info("Cipher text: ${Hex.encodeHexString(cipherBytes)} ${cipherBytes.length}") + + cipher = cipherProvider.getCipher(em, localKey, iv, false) + byte[] recoveredBytes = cipher.doFinal(cipherBytes) + String recovered = new String(recoveredBytes, "UTF-8") + logger.info("Recovered: ${recovered}") + + // Assert + assert plaintext.equals(recovered) + } + } + } + + @Test + public void testShouldRejectEmptyKey() throws Exception { + // Arrange + KeyedCipherProvider cipherProvider = new AESKeyedCipherProvider() + + final EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + + // Act + def msg = shouldFail(IllegalArgumentException) { + cipherProvider.getCipher(encryptionMethod, null, true) + } + + // Assert + assert msg =~ "The key must be specified" + } + + @Test + public void testShouldRejectIncorrectLengthKey() throws Exception { + // Arrange + KeyedCipherProvider cipherProvider = new AESKeyedCipherProvider() + + SecretKey localKey = new SecretKeySpec(Hex.decodeHex("0123456789ABCDEF" as char[]), "AES") + assert ![128, 192, 256].contains(localKey.encoded.length) + + final EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + + // Act + def msg = shouldFail(IllegalArgumentException) { + cipherProvider.getCipher(encryptionMethod, localKey, true) + } + + // Assert + assert msg =~ "The key must be of length \\[128, 192, 256\\]" + } + + @Test + public void testShouldRejectEmptyEncryptionMethod() throws Exception { + // Arrange + KeyedCipherProvider cipherProvider = new AESKeyedCipherProvider() + + // Act + def msg = shouldFail(IllegalArgumentException) { + cipherProvider.getCipher(null, key, true) + } + + // Assert + assert msg =~ "The encryption method must be specified" + } + + @Test + public void testShouldRejectUnsupportedEncryptionMethod() throws Exception { + // Arrange + KeyedCipherProvider cipherProvider = new AESKeyedCipherProvider() + + final EncryptionMethod encryptionMethod = EncryptionMethod.MD5_128AES + + // Act + def msg = shouldFail(IllegalArgumentException) { + cipherProvider.getCipher(encryptionMethod, key, true) + } + + // Assert + assert msg =~ " requires a PBECipherProvider" + } + + @Test + public void testGetCipherShouldSupportExternalCompatibility() throws Exception { + // Arrange + KeyedCipherProvider cipherProvider = new AESKeyedCipherProvider() + + final String PLAINTEXT = "This is a plaintext message." + + // These values can be generated by running `$ ./openssl_aes.rb` in the terminal + final byte[] IV = Hex.decodeHex("e0bc8cc7fbc0bdfdc184dc22ce2fcb5b" as char[]) + final byte[] LOCAL_KEY = Hex.decodeHex("c72943d27c3e5a276169c5998a779117" as char[]) + final String CIPHER_TEXT = "a2725ea55c7dd717664d044cab0f0b5f763653e322c27df21954f5be394efb1b" + byte[] cipherBytes = Hex.decodeHex(CIPHER_TEXT as char[]) + + SecretKey localKey = new SecretKeySpec(LOCAL_KEY, "AES") + + EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()}") + logger.info("Cipher text: ${Hex.encodeHexString(cipherBytes)} ${cipherBytes.length}") + + // Act + Cipher cipher = cipherProvider.getCipher(encryptionMethod, localKey, IV, false) + byte[] recoveredBytes = cipher.doFinal(cipherBytes) + String recovered = new String(recoveredBytes, "UTF-8") + logger.info("Recovered: ${recovered}") + + // Assert + assert PLAINTEXT.equals(recovered) + } + + @Test + public void testGetCipherForDecryptShouldRequireIV() throws Exception { + // Arrange + KeyedCipherProvider cipherProvider = new AESKeyedCipherProvider() + + final String plaintext = "This is a plaintext message." + + // Act + keyedEncryptionMethods.each { EncryptionMethod em -> + logger.info("Using algorithm: ${em.getAlgorithm()}") + byte[] iv = cipherProvider.generateIV() + logger.info("IV: ${Hex.encodeHexString(iv)}") + + // Initialize a cipher for encryption + Cipher cipher = cipherProvider.getCipher(em, key, iv, true) + + byte[] cipherBytes = cipher.doFinal(plaintext.getBytes("UTF-8")) + logger.info("Cipher text: ${Hex.encodeHexString(cipherBytes)} ${cipherBytes.length}") + + def msg = shouldFail(IllegalArgumentException) { + cipher = cipherProvider.getCipher(em, key, false) + } + + // Assert + assert msg =~ "Cannot decrypt without a valid IV" + } + } + + @Test + public void testGetCipherShouldRejectInvalidIVLengths() throws Exception { + // Arrange + KeyedCipherProvider cipherProvider = new AESKeyedCipherProvider() + + final def INVALID_IVS = (0..15).collect { int length -> new byte[length] } + + EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + + // Act + INVALID_IVS.each { byte[] badIV -> + logger.info("IV: ${Hex.encodeHexString(badIV)} ${badIV.length}") + + // Encrypt should print a warning about the bad IV but overwrite it + Cipher cipher = cipherProvider.getCipher(encryptionMethod, key, badIV, true) + + // Decrypt should fail + def msg = shouldFail(IllegalArgumentException) { + cipher = cipherProvider.getCipher(encryptionMethod, key, badIV, false) + } + logger.expected(msg) + + // Assert + assert msg =~ "Cannot decrypt without a valid IV" + } + } + + @Test + public void testGetCipherShouldRejectEmptyIV() throws Exception { + // Arrange + KeyedCipherProvider cipherProvider = new AESKeyedCipherProvider() + + EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + + byte[] badIV = [0x00 as byte] * 16 as byte[] + + // Act + logger.info("IV: ${Hex.encodeHexString(badIV)} ${badIV.length}") + + // Encrypt should print a warning about the bad IV but overwrite it + Cipher cipher = cipherProvider.getCipher(encryptionMethod, key, badIV, true) + logger.info("IV after encrypt: ${Hex.encodeHexString(cipher.getIV())}") + + // Decrypt should fail + def msg = shouldFail(IllegalArgumentException) { + cipher = cipherProvider.getCipher(encryptionMethod, key, badIV, false) + } + logger.expected(msg) + + // Assert + assert msg =~ "Cannot decrypt without a valid IV" + } +} \ No newline at end of file
http://git-wip-us.apache.org/repos/asf/nifi/blob/498b5023/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/BcryptCipherProviderGroovyTest.groovy ---------------------------------------------------------------------- diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/BcryptCipherProviderGroovyTest.groovy b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/BcryptCipherProviderGroovyTest.groovy new file mode 100644 index 0000000..84b91c6 --- /dev/null +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/BcryptCipherProviderGroovyTest.groovy @@ -0,0 +1,549 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.nifi.processors.standard.util.crypto + +import org.apache.commons.codec.binary.Base64 +import org.apache.commons.codec.binary.Hex +import org.apache.nifi.processors.standard.util.crypto.bcrypt.BCrypt +import org.apache.nifi.security.util.EncryptionMethod +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.junit.After +import org.junit.Assume +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.slf4j.Logger + +//import org.mindrot.jbcrypt.BCrypt +import org.slf4j.LoggerFactory + +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import java.security.Security + +import static groovy.test.GroovyAssert.shouldFail +import static org.junit.Assert.assertTrue + +@RunWith(JUnit4.class) +public class BcryptCipherProviderGroovyTest { + private static final Logger logger = LoggerFactory.getLogger(BcryptCipherProviderGroovyTest.class); + + private static List<EncryptionMethod> strongKDFEncryptionMethods + + private static final int DEFAULT_KEY_LENGTH = 128; + public static final String MICROBENCHMARK = "microbenchmark" + private static ArrayList<Integer> AES_KEY_LENGTHS + + @BeforeClass + public static void setUpOnce() throws Exception { + Security.addProvider(new BouncyCastleProvider()); + + strongKDFEncryptionMethods = EncryptionMethod.values().findAll { it.isCompatibleWithStrongKDFs() } + + logger.metaClass.methodMissing = { String name, args -> + logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}") + } + + if (PasswordBasedEncryptor.supportsUnlimitedStrength()) { + AES_KEY_LENGTHS = [128, 192, 256] + } else { + AES_KEY_LENGTHS = [128] + } + } + + @Before + public void setUp() throws Exception { + } + + @After + public void tearDown() throws Exception { + + } + + @Test + public void testGetCipherShouldBeInternallyConsistent() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(4) + + final String PASSWORD = "shortPassword"; + final byte[] SALT = cipherProvider.generateSalt() + + final String plaintext = "This is a plaintext message."; + + // Act + for (EncryptionMethod em : strongKDFEncryptionMethods) { + logger.info("Using algorithm: ${em.getAlgorithm()}"); + + // Initialize a cipher for encryption + Cipher cipher = cipherProvider.getCipher(em, PASSWORD, SALT, DEFAULT_KEY_LENGTH, true); + byte[] iv = cipher.getIV(); + logger.info("IV: ${Hex.encodeHexString(iv)}") + + byte[] cipherBytes = cipher.doFinal(plaintext.getBytes("UTF-8")); + logger.info("Cipher text: ${Hex.encodeHexString(cipherBytes)} ${cipherBytes.length}"); + + cipher = cipherProvider.getCipher(em, PASSWORD, SALT, iv, DEFAULT_KEY_LENGTH, false); + byte[] recoveredBytes = cipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + logger.info("Recovered: ${recovered}") + + // Assert + assert plaintext.equals(recovered); + } + } + + @Test + public void testGetCipherWithExternalIVShouldBeInternallyConsistent() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(4) + + final String PASSWORD = "shortPassword"; + final byte[] SALT = cipherProvider.generateSalt() + final byte[] IV = Hex.decodeHex("01" * 16 as char[]); + + final String plaintext = "This is a plaintext message."; + + // Act + for (EncryptionMethod em : strongKDFEncryptionMethods) { + logger.info("Using algorithm: ${em.getAlgorithm()}"); + + // Initialize a cipher for encryption + Cipher cipher = cipherProvider.getCipher(em, PASSWORD, SALT, IV, DEFAULT_KEY_LENGTH, true); + logger.info("IV: ${Hex.encodeHexString(IV)}") + + byte[] cipherBytes = cipher.doFinal(plaintext.getBytes("UTF-8")); + logger.info("Cipher text: ${Hex.encodeHexString(cipherBytes)} ${cipherBytes.length}"); + + cipher = cipherProvider.getCipher(em, PASSWORD, SALT, IV, DEFAULT_KEY_LENGTH, false); + byte[] recoveredBytes = cipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + logger.info("Recovered: ${recovered}") + + // Assert + assert plaintext.equals(recovered); + } + } + + @Test + public void testGetCipherWithUnlimitedStrengthShouldBeInternallyConsistent() throws Exception { + // Arrange + Assume.assumeTrue("Test is being skipped due to this JVM lacking JCE Unlimited Strength Jurisdiction Policy file.", + PasswordBasedEncryptor.supportsUnlimitedStrength()); + + RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(4) + + final String PASSWORD = "shortPassword"; + final byte[] SALT = cipherProvider.generateSalt() + + final int LONG_KEY_LENGTH = 256 + + final String plaintext = "This is a plaintext message."; + + // Act + for (EncryptionMethod em : strongKDFEncryptionMethods) { + logger.info("Using algorithm: ${em.getAlgorithm()}"); + + // Initialize a cipher for encryption + Cipher cipher = cipherProvider.getCipher(em, PASSWORD, SALT, LONG_KEY_LENGTH, true); + byte[] iv = cipher.getIV(); + logger.info("IV: ${Hex.encodeHexString(iv)}") + + byte[] cipherBytes = cipher.doFinal(plaintext.getBytes("UTF-8")); + logger.info("Cipher text: ${Hex.encodeHexString(cipherBytes)} ${cipherBytes.length}"); + + cipher = cipherProvider.getCipher(em, PASSWORD, SALT, iv, LONG_KEY_LENGTH, false); + byte[] recoveredBytes = cipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + logger.info("Recovered: ${recovered}") + + // Assert + assert plaintext.equals(recovered); + } + } + + @Test + public void testHashPWShouldMatchTestVectors() { + // Arrange + final String PASSWORD = 'abcdefghijklmnopqrstuvwxyz' + final String SALT = '$2a$10$fVH8e28OQRj9tqiDXs1e1u' + final String EXPECTED_HASH = '$2a$10$fVH8e28OQRj9tqiDXs1e1uxpsjN0c7II7YPKXua2NAKYvM6iQk7dq' +// final int WORK_FACTOR = 10 + + // Act + String calculatedHash = BCrypt.hashpw(PASSWORD, SALT) + logger.info("Generated ${calculatedHash}") + + // Assert + assert calculatedHash == EXPECTED_HASH + } + + @Test + public void testGetCipherShouldSupportExternalCompatibility() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(4) + + final String PLAINTEXT = "This is a plaintext message."; + final String PASSWORD = "thisIsABadPassword"; + + // These values can be generated by running `$ ./openssl_bcrypt` in the terminal + final byte[] SALT = Hex.decodeHex("81455b915ce9efd1fc61a08eb0255936" as char[]); + final byte[] IV = Hex.decodeHex("41a51e0150df6a1f72826b36c6371f3f" as char[]); + + // $v2$w2$base64_salt_22__base64_hash_31 + final String FULL_HASH = "\$2a\$10\$gUVbkVzp79H8YaCOsCVZNuz/d759nrMKzjuviaS5/WdcKHzqngGKi" + logger.info("Full Hash: ${FULL_HASH}") + final String HASH = FULL_HASH[-31..-1] + logger.info(" Hash: ${HASH.padLeft(60, " ")}") + logger.info(" B64 Salt: ${CipherUtility.encodeBase64NoPadding(SALT).padLeft(29, " ")}") + + String extractedSalt = FULL_HASH[7..<29] + logger.info("Extracted Salt: ${extractedSalt}") + String extractedSaltHex = Hex.encodeHexString(Base64.decodeBase64(extractedSalt)) + logger.info("Extracted Salt (hex): ${extractedSaltHex}") + logger.info(" Expected Salt (hex): ${Hex.encodeHexString(SALT)}") + + final String CIPHER_TEXT = "3a226ba2b3c8fe559acb806620001246db289375ba8075a68573478b56a69f15" + byte[] cipherBytes = Hex.decodeHex(CIPHER_TEXT as char[]) + + EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()}"); + logger.info("External cipher text: ${Hex.encodeHexString(cipherBytes)} ${cipherBytes.length}"); + + // Sanity check + Cipher rubyCipher = Cipher.getInstance(encryptionMethod.algorithm, "BC") + def rubyKey = new SecretKeySpec(Hex.decodeHex("724cd9e1b0b9e87c7f7e7d7b270bca07" as char[]), "AES") + def ivSpec = new IvParameterSpec(IV) + rubyCipher.init(Cipher.ENCRYPT_MODE, rubyKey, ivSpec) + byte[] rubyCipherBytes = rubyCipher.doFinal(PLAINTEXT.bytes) + logger.info("Expected cipher text: ${Hex.encodeHexString(rubyCipherBytes)}") + rubyCipher.init(Cipher.DECRYPT_MODE, rubyKey, ivSpec) + assert rubyCipher.doFinal(rubyCipherBytes) == PLAINTEXT.bytes + assert rubyCipher.doFinal(cipherBytes) == PLAINTEXT.bytes + logger.sanity("Decrypted external cipher text and generated cipher text successfully") + + // Sanity for hash generation + final String FULL_SALT = FULL_HASH[0..<29] + logger.sanity("Salt from external: ${FULL_SALT}") + String generatedHash = BCrypt.hashpw(PASSWORD, FULL_SALT) + logger.sanity("Generated hash: ${generatedHash}") + assert generatedHash == FULL_HASH + + // Act + Cipher cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, FULL_SALT.bytes, IV, DEFAULT_KEY_LENGTH, false); + byte[] recoveredBytes = cipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + logger.info("Recovered: ${recovered}") + + // Assert + assert PLAINTEXT.equals(recovered); + } + + @Test + public void testGetCipherShouldHandleFullSalt() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(4) + + final String PLAINTEXT = "This is a plaintext message."; + final String PASSWORD = "thisIsABadPassword"; + + // These values can be generated by running `$ ./openssl_bcrypt.rb` in the terminal + final byte[] IV = Hex.decodeHex("41a51e0150df6a1f72826b36c6371f3f" as char[]); + + // $v2$w2$base64_salt_22__base64_hash_31 + final String FULL_HASH = "\$2a\$10\$gUVbkVzp79H8YaCOsCVZNuz/d759nrMKzjuviaS5/WdcKHzqngGKi" + logger.info("Full Hash: ${FULL_HASH}") + final String FULL_SALT = FULL_HASH[0..<29] + logger.info(" Salt: ${FULL_SALT}") + final String HASH = FULL_HASH[-31..-1] + logger.info(" Hash: ${HASH.padLeft(60, " ")}") + + String extractedSalt = FULL_HASH[7..<29] + logger.info("Extracted Salt: ${extractedSalt}") + String extractedSaltHex = Hex.encodeHexString(Base64.decodeBase64(extractedSalt)) + logger.info("Extracted Salt (hex): ${extractedSaltHex}") + + final String CIPHER_TEXT = "3a226ba2b3c8fe559acb806620001246db289375ba8075a68573478b56a69f15" + byte[] cipherBytes = Hex.decodeHex(CIPHER_TEXT as char[]) + + EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()}"); + logger.info("External cipher text: ${Hex.encodeHexString(cipherBytes)} ${cipherBytes.length}"); + + // Act + Cipher cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, FULL_SALT.bytes, IV, DEFAULT_KEY_LENGTH, false); + byte[] recoveredBytes = cipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + logger.info("Recovered: ${recovered}") + + // Assert + assert PLAINTEXT.equals(recovered); + } + + @Test + public void testGetCipherShouldHandleUnformedSalt() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(4) + + final String PASSWORD = "thisIsABadPassword"; + + final def INVALID_SALTS = ['$ab$00$acbdefghijklmnopqrstuv', 'bad_salt', '$3a$11$', 'x', '$2a$10$'] + + EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()}"); + + // Act + INVALID_SALTS.each { String salt -> + logger.info("Checking salt ${salt}") + + def msg = shouldFail(IllegalArgumentException) { + Cipher cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, salt.bytes, DEFAULT_KEY_LENGTH, true); + } + + // Assert + assert msg =~ "The salt must be of the format \\\$2a\\\$10\\\$gUVbkVzp79H8YaCOsCVZNu\\. To generate a salt, use BcryptCipherProvider#generateSalt" + } + } + + String bytesToBitString(byte[] bytes) { + bytes.collect { + String.format("%8s", Integer.toBinaryString(it & 0xFF)).replace(' ', '0') + }.join("") + } + + String spaceString(String input, int blockSize = 4) { + input.collect { it.padLeft(blockSize, " ") }.join("") + } + + @Test + public void testGetCipherShouldRejectEmptySalt() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(4) + + final String PASSWORD = "thisIsABadPassword"; + + EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()}"); + + // Two different errors -- one explaining the no-salt method is not supported, and the other for an empty byte[] passed + + // Act + def msg = shouldFail(UnsupportedOperationException) { + Cipher cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, DEFAULT_KEY_LENGTH, true); + } + logger.expected(msg) + + // Assert + assert msg =~ "The cipher cannot be initialized without a valid salt\\. Use BcryptCipherProvider#generateSalt\\(\\) to generate a valid salt" + + // Act + msg = shouldFail(IllegalArgumentException) { + Cipher cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, new byte[0], DEFAULT_KEY_LENGTH, true); + } + logger.expected(msg) + + // Assert + assert msg =~ "The salt cannot be empty\\. To generate a salt, use BcryptCipherProvider#generateSalt" + } + + @Test + public void testGetCipherForDecryptShouldRequireIV() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(4) + + final String PASSWORD = "shortPassword"; + final byte[] SALT = cipherProvider.generateSalt() + final byte[] IV = Hex.decodeHex("00" * 16 as char[]); + + final String plaintext = "This is a plaintext message."; + + // Act + for (EncryptionMethod em : strongKDFEncryptionMethods) { + logger.info("Using algorithm: ${em.getAlgorithm()}"); + + // Initialize a cipher for encryption + Cipher cipher = cipherProvider.getCipher(em, PASSWORD, SALT, IV, DEFAULT_KEY_LENGTH, true); + logger.info("IV: ${Hex.encodeHexString(IV)}") + + byte[] cipherBytes = cipher.doFinal(plaintext.getBytes("UTF-8")); + logger.info("Cipher text: ${Hex.encodeHexString(cipherBytes)} ${cipherBytes.length}"); + + def msg = shouldFail(IllegalArgumentException) { + cipher = cipherProvider.getCipher(em, PASSWORD, SALT, DEFAULT_KEY_LENGTH, false); + } + + // Assert + assert msg =~ "Cannot decrypt without a valid IV" + } + } + + @Test + public void testGetCipherShouldAcceptValidKeyLengths() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(4); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = cipherProvider.generateSalt() + final byte[] IV = Hex.decodeHex("01" * 16 as char[]); + + final String PLAINTEXT = "This is a plaintext message."; + + // Currently only AES ciphers are compatible with Bcrypt, so redundant to test all algorithms + final def VALID_KEY_LENGTHS = AES_KEY_LENGTHS + EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + + // Act + VALID_KEY_LENGTHS.each { int keyLength -> + logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()} with key length ${keyLength}") + + // Initialize a cipher for encryption + Cipher cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, SALT, IV, keyLength, true); + logger.info("IV: ${Hex.encodeHexString(IV)}") + + byte[] cipherBytes = cipher.doFinal(PLAINTEXT.getBytes("UTF-8")); + logger.info("Cipher text: ${Hex.encodeHexString(cipherBytes)} ${cipherBytes.length}"); + + cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, SALT, IV, keyLength, false); + byte[] recoveredBytes = cipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + logger.info("Recovered: ${recovered}") + + // Assert + assert PLAINTEXT.equals(recovered); + } + } + + @Test + public void testGetCipherShouldNotAcceptInvalidKeyLengths() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(4); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = cipherProvider.generateSalt() + final byte[] IV = Hex.decodeHex("00" * 16 as char[]); + + final String PLAINTEXT = "This is a plaintext message."; + + // Currently only AES ciphers are compatible with Bcrypt, so redundant to test all algorithms + final def INVALID_KEY_LENGTHS = [-1, 40, 64, 112, 512] + EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + + // Act + INVALID_KEY_LENGTHS.each { int keyLength -> + logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()} with key length ${keyLength}") + + // Initialize a cipher for encryption + def msg = shouldFail(IllegalArgumentException) { + Cipher cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, SALT, IV, keyLength, true); + } + + // Assert + assert msg =~ "${keyLength} is not a valid key length for AES" + } + } + + @Test + public void testGenerateSaltShouldUseProvidedWorkFactor() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(11); + int workFactor = cipherProvider.getWorkFactor() + + // Act + final byte[] saltBytes = cipherProvider.generateSalt() + String salt = new String(saltBytes) + logger.info("Salt: ${salt}") + + // Assert + assert salt =~ /^\$2[axy]\$\d{2}\$/ + assert salt.contains("\$${workFactor}\$") + } + + @Ignore("This test can be run on a specific machine to evaluate if the default work factor is sufficient") + @Test + public void testDefaultConstructorShouldProvideStrongWorkFactor() { + // Arrange + RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(); + + // Values taken from http://wildlyinaccurate.com/bcrypt-choosing-a-work-factor/ and http://security.stackexchange.com/questions/17207/recommended-of-rounds-for-bcrypt + + // Calculate the work factor to reach 500 ms + int minimumWorkFactor = calculateMinimumWorkFactor() + logger.info("Determined minimum safe work factor to be ${minimumWorkFactor}") + + // Act + int workFactor = cipherProvider.getWorkFactor() + logger.info("Default work factor ${workFactor}") + + // Assert + assertTrue("The default work factor for BcryptCipherProvider is too weak. Please update the default value to a stronger level.", workFactor >= minimumWorkFactor) + } + + /** + * Returns the work factor required for a derivation to exceed 500 ms on this machine. Code adapted from http://security.stackexchange.com/questions/17207/recommended-of-rounds-for-bcrypt + * + * @return the minimum bcrypt work factor + */ + private static int calculateMinimumWorkFactor() { + // High start-up cost, so run multiple times for better benchmarking + final int RUNS = 10 + + // Benchmark using a work factor of 5 (the second-lowest allowed) + int workFactor = 5 + + String salt = BCrypt.gensalt(workFactor) + + // Run once to prime the system + double duration = time { + BCrypt.hashpw(MICROBENCHMARK, salt) + } + logger.info("First run of work factor ${workFactor} took ${duration} ms (ignored)") + + def durations = [] + + RUNS.times { int i -> + duration = time { + BCrypt.hashpw(MICROBENCHMARK, salt) + } + logger.info("Work factor ${workFactor} took ${duration} ms") + durations << duration + } + + duration = durations.sum() / durations.size() + logger.info("Work factor ${workFactor} averaged ${duration} ms") + + // Increasing the work factor by 1 would double the run time + // Keep increasing N until the estimated duration is over 500 ms + while (duration < 500) { + workFactor += 1 + duration *= 2 + } + + logger.info("Returning work factor ${workFactor} for ${duration} ms") + + return workFactor + } + + private static double time(Closure c) { + long start = System.nanoTime() + c.call() + long end = System.nanoTime() + return (end - start) / 1_000_000.0 + } +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/nifi/blob/498b5023/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/CipherProviderFactoryGroovyTest.groovy ---------------------------------------------------------------------- diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/CipherProviderFactoryGroovyTest.groovy b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/CipherProviderFactoryGroovyTest.groovy new file mode 100644 index 0000000..be8d5f4 --- /dev/null +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/CipherProviderFactoryGroovyTest.groovy @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.nifi.processors.standard.util.crypto + +import org.apache.nifi.security.util.KeyDerivationFunction +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.junit.After +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +import java.security.Security + +@RunWith(JUnit4.class) +class CipherProviderFactoryGroovyTest extends GroovyTestCase { + private static final Logger logger = LoggerFactory.getLogger(CipherProviderFactoryGroovyTest.class) + + private static final Map<KeyDerivationFunction, Class> EXPECTED_CIPHER_PROVIDERS = [ + (KeyDerivationFunction.BCRYPT) : BcryptCipherProvider.class, + (KeyDerivationFunction.NIFI_LEGACY) : NiFiLegacyCipherProvider.class, + (KeyDerivationFunction.NONE) : AESKeyedCipherProvider.class, + (KeyDerivationFunction.OPENSSL_EVP_BYTES_TO_KEY): OpenSSLPKCS5CipherProvider.class, + (KeyDerivationFunction.PBKDF2) : PBKDF2CipherProvider.class, + (KeyDerivationFunction.SCRYPT) : ScryptCipherProvider.class + ] + + @BeforeClass + public static void setUpOnce() throws Exception { + Security.addProvider(new BouncyCastleProvider()) + + logger.metaClass.methodMissing = { String name, args -> + logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}") + } + } + + @Before + public void setUp() throws Exception { + } + + @After + public void tearDown() throws Exception { + } + + @Test + public void testGetCipherProviderShouldResolveRegisteredKDFs() { + // Arrange + + // Act + KeyDerivationFunction.values().each { KeyDerivationFunction kdf -> + logger.info("Expected: ${kdf.name} -> ${EXPECTED_CIPHER_PROVIDERS.get(kdf).simpleName}") + CipherProvider cp = CipherProviderFactory.getCipherProvider(kdf) + logger.info("Resolved: ${kdf.name} -> ${cp.class.simpleName}") + + // Assert + assert cp.class == (EXPECTED_CIPHER_PROVIDERS.get(kdf)) + } + } + + @Ignore("Cannot mock enum using Groovy map coercion") + @Test + public void testGetCipherProviderShouldHandleUnregisteredKDFs() { + // Arrange + + // Can't mock this; see http://stackoverflow.com/questions/5323505/mocking-java-enum-to-add-a-value-to-test-fail-case + KeyDerivationFunction invalidKDF = [name: "Unregistered", description: "Not a registered KDF"] as KeyDerivationFunction + logger.info("Expected: ${invalidKDF.name} -> error") + + // Act + def msg = shouldFail(IllegalArgumentException) { + CipherProvider cp = CipherProviderFactory.getCipherProvider(invalidKDF) + logger.info("Resolved: ${invalidKDF.name} -> ${cp.class.simpleName}") + } + logger.expected(msg) + + // Assert + assert msg =~ "No cipher provider registered for ${invalidKDF.name}" + } +} http://git-wip-us.apache.org/repos/asf/nifi/blob/498b5023/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/CipherUtilityGroovyTest.groovy ---------------------------------------------------------------------- diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/CipherUtilityGroovyTest.groovy b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/CipherUtilityGroovyTest.groovy new file mode 100644 index 0000000..6a6a958 --- /dev/null +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/CipherUtilityGroovyTest.groovy @@ -0,0 +1,251 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.nifi.processors.standard.util.crypto + +import org.apache.nifi.security.util.EncryptionMethod +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.junit.After +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +import java.security.Security + +@RunWith(JUnit4.class) +class CipherUtilityGroovyTest extends GroovyTestCase { + private static final Logger logger = LoggerFactory.getLogger(CipherUtilityGroovyTest.class) + + // TripleDES must precede DES for automatic grouping precedence + private static final List<String> CIPHERS = ["AES", "TRIPLEDES", "DES", "RC2", "RC4", "RC5", "TWOFISH"] + private static final List<String> SYMMETRIC_ALGORITHMS = EncryptionMethod.values().findAll { it.algorithm.startsWith("PBE") || it.algorithm.startsWith("AES") }*.algorithm + private static final Map<String, List<String>> ALGORITHMS_MAPPED_BY_CIPHER = SYMMETRIC_ALGORITHMS.groupBy { String algorithm -> CIPHERS.find { algorithm.contains(it) } } + + // Manually mapped as of 01/19/16 0.5.0 + private static final Map<Integer, List<String>> ALGORITHMS_MAPPED_BY_KEY_LENGTH = [ + (40) : ["PBEWITHSHAAND40BITRC2-CBC", + "PBEWITHSHAAND40BITRC4"], + (64) : ["PBEWITHMD5ANDDES", + "PBEWITHSHA1ANDDES"], + (112): ["PBEWITHSHAAND2-KEYTRIPLEDES-CBC", + "PBEWITHSHAAND3-KEYTRIPLEDES-CBC"], + (128): ["PBEWITHMD5AND128BITAES-CBC-OPENSSL", + "PBEWITHMD5ANDRC2", + "PBEWITHSHA1ANDRC2", + "PBEWITHSHA256AND128BITAES-CBC-BC", + "PBEWITHSHAAND128BITAES-CBC-BC", + "PBEWITHSHAAND128BITRC2-CBC", + "PBEWITHSHAAND128BITRC4", + "PBEWITHSHAANDTWOFISH-CBC", + "AES/CBC/PKCS7Padding", + "AES/CTR/NoPadding", + "AES/GCM/NoPadding"], + (192): ["PBEWITHMD5AND192BITAES-CBC-OPENSSL", + "PBEWITHSHA256AND192BITAES-CBC-BC", + "PBEWITHSHAAND192BITAES-CBC-BC", + "AES/CBC/PKCS7Padding", + "AES/CTR/NoPadding", + "AES/GCM/NoPadding"], + (256): ["PBEWITHMD5AND256BITAES-CBC-OPENSSL", + "PBEWITHSHA256AND256BITAES-CBC-BC", + "PBEWITHSHAAND256BITAES-CBC-BC", + "AES/CBC/PKCS7Padding", + "AES/CTR/NoPadding", + "AES/GCM/NoPadding"] + ] + + @BeforeClass + static void setUpOnce() { + Security.addProvider(new BouncyCastleProvider()); + + // Fix because TRIPLEDES -> DESede + def tripleDESAlgorithms = ALGORITHMS_MAPPED_BY_CIPHER.remove("TRIPLEDES") + ALGORITHMS_MAPPED_BY_CIPHER.put("DESede", tripleDESAlgorithms) + + logger.info("Mapped algorithms: ${ALGORITHMS_MAPPED_BY_CIPHER}") + } + + @Before + void setUp() throws Exception { + + } + + @After + void tearDown() throws Exception { + + } + + @Test + void testShouldParseCipherFromAlgorithm() { + // Arrange + final def EXPECTED_ALGORITHMS = ALGORITHMS_MAPPED_BY_CIPHER + + // Act + SYMMETRIC_ALGORITHMS.each { String algorithm -> + String cipher = CipherUtility.parseCipherFromAlgorithm(algorithm) + logger.info("Extracted ${cipher} from ${algorithm}") + + // Assert + assert EXPECTED_ALGORITHMS.get(cipher).contains(algorithm) + } + } + + @Test + void testShouldParseKeyLengthFromAlgorithm() { + // Arrange + final def EXPECTED_ALGORITHMS = ALGORITHMS_MAPPED_BY_KEY_LENGTH + + // Act + SYMMETRIC_ALGORITHMS.each { String algorithm -> + int keyLength = CipherUtility.parseKeyLengthFromAlgorithm(algorithm) + logger.info("Extracted ${keyLength} from ${algorithm}") + + // Assert + assert EXPECTED_ALGORITHMS.get(keyLength).contains(algorithm) + } + } + + @Test + void testShouldDetermineValidKeyLength() { + // Arrange + + // Act + ALGORITHMS_MAPPED_BY_KEY_LENGTH.each { int keyLength, List<String> algorithms -> + algorithms.each { String algorithm -> + logger.info("Checking ${keyLength} for ${algorithm}") + + // Assert + assert CipherUtility.isValidKeyLength(keyLength, CipherUtility.parseCipherFromAlgorithm(algorithm)) + } + } + } + + @Test + void testShouldDetermineInvalidKeyLength() { + // Arrange + + // Act + ALGORITHMS_MAPPED_BY_KEY_LENGTH.each { int keyLength, List<String> algorithms -> + algorithms.each { String algorithm -> + def invalidKeyLengths = [-1, 0, 1] + if (algorithm =~ "RC\\d") { + invalidKeyLengths += [39, 2049] + } else { + invalidKeyLengths += keyLength + 1 + } + logger.info("Checking ${invalidKeyLengths.join(", ")} for ${algorithm}") + + // Assert + invalidKeyLengths.each { int invalidKeyLength -> + assert !CipherUtility.isValidKeyLength(invalidKeyLength, CipherUtility.parseCipherFromAlgorithm(algorithm)) + } + } + } + } + + @Test + void testShouldDetermineValidKeyLengthForAlgorithm() { + // Arrange + + // Act + ALGORITHMS_MAPPED_BY_KEY_LENGTH.each { int keyLength, List<String> algorithms -> + algorithms.each { String algorithm -> + logger.info("Checking ${keyLength} for ${algorithm}") + + // Assert + assert CipherUtility.isValidKeyLengthForAlgorithm(keyLength, algorithm) + } + } + } + + @Test + void testShouldDetermineInvalidKeyLengthForAlgorithm() { + // Arrange + + // Act + ALGORITHMS_MAPPED_BY_KEY_LENGTH.each { int keyLength, List<String> algorithms -> + algorithms.each { String algorithm -> + def invalidKeyLengths = [-1, 0, 1] + if (algorithm =~ "RC\\d") { + invalidKeyLengths += [39, 2049] + } else { + invalidKeyLengths += keyLength + 1 + } + logger.info("Checking ${invalidKeyLengths.join(", ")} for ${algorithm}") + + // Assert + invalidKeyLengths.each { int invalidKeyLength -> + assert !CipherUtility.isValidKeyLengthForAlgorithm(invalidKeyLength, algorithm) + } + } + } + + // Extra hard-coded checks + String algorithm = "PBEWITHSHA256AND256BITAES-CBC-BC" + int invalidKeyLength = 192 + logger.info("Checking ${invalidKeyLength} for ${algorithm}") + assert !CipherUtility.isValidKeyLengthForAlgorithm(invalidKeyLength, algorithm) + } + + @Test + void testShouldGetValidKeyLengthsForAlgorithm() { + // Arrange + + def rcKeyLengths = (40..2048).asList() + def CIPHER_KEY_SIZES = [ + AES : [128, 192, 256], + DES : [56, 64], + DESede : [56, 64, 112, 128, 168, 192], + RC2 : rcKeyLengths, + RC4 : rcKeyLengths, + RC5 : rcKeyLengths, + TWOFISH: [128, 192, 256] + ] + + def SINGLE_KEY_SIZE_ALGORITHMS = EncryptionMethod.values()*.algorithm.findAll { CipherUtility.parseActualKeyLengthFromAlgorithm(it) != -1 } + logger.info("Single key size algorithms: ${SINGLE_KEY_SIZE_ALGORITHMS}") + def MULTIPLE_KEY_SIZE_ALGORITHMS = EncryptionMethod.values()*.algorithm - SINGLE_KEY_SIZE_ALGORITHMS + MULTIPLE_KEY_SIZE_ALGORITHMS.removeAll { it.contains("PGP") } + logger.info("Multiple key size algorithms: ${MULTIPLE_KEY_SIZE_ALGORITHMS}") + + // Act + SINGLE_KEY_SIZE_ALGORITHMS.each { String algorithm -> + def EXPECTED_KEY_SIZES = [CipherUtility.parseKeyLengthFromAlgorithm(algorithm)] + + def validKeySizes = CipherUtility.getValidKeyLengthsForAlgorithm(algorithm) + logger.info("Checking ${algorithm} ${validKeySizes} against expected ${EXPECTED_KEY_SIZES}") + + // Assert + assert validKeySizes == EXPECTED_KEY_SIZES + } + + // Act + MULTIPLE_KEY_SIZE_ALGORITHMS.each { String algorithm -> + String cipher = CipherUtility.parseCipherFromAlgorithm(algorithm) + def EXPECTED_KEY_SIZES = CIPHER_KEY_SIZES[cipher] + + def validKeySizes = CipherUtility.getValidKeyLengthsForAlgorithm(algorithm) + logger.info("Checking ${algorithm} ${validKeySizes} against expected ${EXPECTED_KEY_SIZES}") + + // Assert + assert validKeySizes == EXPECTED_KEY_SIZES + } + } +} http://git-wip-us.apache.org/repos/asf/nifi/blob/498b5023/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/KeyedEncryptorGroovyTest.groovy ---------------------------------------------------------------------- diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/KeyedEncryptorGroovyTest.groovy b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/KeyedEncryptorGroovyTest.groovy new file mode 100644 index 0000000..8e78778 --- /dev/null +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/KeyedEncryptorGroovyTest.groovy @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.nifi.processors.standard.util.crypto + +import org.apache.commons.codec.binary.Hex +import org.apache.nifi.processor.io.StreamCallback +import org.apache.nifi.security.util.EncryptionMethod +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.junit.After +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec +import java.security.Security + +public class KeyedEncryptorGroovyTest { + private static final Logger logger = LoggerFactory.getLogger(KeyedEncryptorGroovyTest.class) + + private static final String TEST_RESOURCES_PREFIX = "src/test/resources/TestEncryptContent/" + private static final File plainFile = new File("${TEST_RESOURCES_PREFIX}/plain.txt") + private static final File encryptedFile = new File("${TEST_RESOURCES_PREFIX}/unsalted_128_raw.asc") + + private static final String KEY_HEX = "0123456789ABCDEFFEDCBA9876543210" + private static final SecretKey KEY = new SecretKeySpec(Hex.decodeHex(KEY_HEX as char[]), "AES") + + @BeforeClass + public static void setUpOnce() throws Exception { + Security.addProvider(new BouncyCastleProvider()) + + logger.metaClass.methodMissing = { String name, args -> + logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}") + } + } + + @Before + public void setUp() throws Exception { + } + + @After + public void tearDown() throws Exception { + } + + @Test + public void testShouldEncryptAndDecrypt() throws Exception { + // Arrange + final String PLAINTEXT = "This is a plaintext message." + logger.info("Plaintext: {}", PLAINTEXT) + InputStream plainStream = new ByteArrayInputStream(PLAINTEXT.getBytes("UTF-8")) + + OutputStream cipherStream = new ByteArrayOutputStream() + OutputStream recoveredStream = new ByteArrayOutputStream() + + EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + logger.info("Using ${encryptionMethod.name()}") + + // Act + KeyedEncryptor encryptor = new KeyedEncryptor(encryptionMethod, KEY) + + StreamCallback encryptionCallback = encryptor.getEncryptionCallback() + StreamCallback decryptionCallback = encryptor.getDecryptionCallback() + + encryptionCallback.process(plainStream, cipherStream) + + final byte[] cipherBytes = ((ByteArrayOutputStream) cipherStream).toByteArray() + logger.info("Encrypted: {}", Hex.encodeHexString(cipherBytes)) + InputStream cipherInputStream = new ByteArrayInputStream(cipherBytes) + decryptionCallback.process(cipherInputStream, recoveredStream) + + // Assert + byte[] recoveredBytes = ((ByteArrayOutputStream) recoveredStream).toByteArray() + String recovered = new String(recoveredBytes, "UTF-8") + logger.info("Recovered: {}\n\n", recovered) + assert PLAINTEXT.equals(recovered) + } + + @Test + public void testShouldDecryptOpenSSLUnsaltedCipherTextWithKnownIV() throws Exception { + // Arrange + final String PLAINTEXT = new File("${TEST_RESOURCES_PREFIX}/plain.txt").text + logger.info("Plaintext: {}", PLAINTEXT) + byte[] cipherBytes = new File("${TEST_RESOURCES_PREFIX}/unsalted_128_raw.enc").bytes + + final String keyHex = "711E85689CE7AFF6F410AEA43ABC5446" + final String ivHex = "842F685B84879B2E00F977C22B9E9A7D" + + InputStream cipherStream = new ByteArrayInputStream(cipherBytes) + OutputStream recoveredStream = new ByteArrayOutputStream() + + final EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + KeyedEncryptor encryptor = new KeyedEncryptor(encryptionMethod, new SecretKeySpec(Hex.decodeHex(keyHex as char[]), "AES"), Hex.decodeHex(ivHex as char[])) + + StreamCallback decryptionCallback = encryptor.getDecryptionCallback() + logger.info("Cipher bytes: ${Hex.encodeHexString(cipherBytes)}") + + // Act + decryptionCallback.process(cipherStream, recoveredStream) + + // Assert + byte[] recoveredBytes = ((ByteArrayOutputStream) recoveredStream).toByteArray() + String recovered = new String(recoveredBytes, "UTF-8") + logger.info("Recovered: {}", recovered) + assert PLAINTEXT.equals(recovered) + } +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/nifi/blob/498b5023/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/NiFiLegacyCipherProviderGroovyTest.groovy ---------------------------------------------------------------------- diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/NiFiLegacyCipherProviderGroovyTest.groovy b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/NiFiLegacyCipherProviderGroovyTest.groovy new file mode 100644 index 0000000..0472fa3 --- /dev/null +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/NiFiLegacyCipherProviderGroovyTest.groovy @@ -0,0 +1,288 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.nifi.processors.standard.util.crypto + +import org.apache.commons.codec.binary.Hex +import org.apache.nifi.security.util.EncryptionMethod +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.junit.* +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.PBEParameterSpec +import java.security.Security + +import static org.junit.Assert.fail + +@RunWith(JUnit4.class) +public class NiFiLegacyCipherProviderGroovyTest { + private static final Logger logger = LoggerFactory.getLogger(NiFiLegacyCipherProviderGroovyTest.class); + + private static List<EncryptionMethod> pbeEncryptionMethods = new ArrayList<>(); + private static List<EncryptionMethod> limitedStrengthPbeEncryptionMethods = new ArrayList<>(); + + private static final String PROVIDER_NAME = "BC"; + private static final int ITERATION_COUNT = 1000; + + @BeforeClass + public static void setUpOnce() throws Exception { + Security.addProvider(new BouncyCastleProvider()); + + pbeEncryptionMethods = EncryptionMethod.values().findAll { it.algorithm.toUpperCase().startsWith("PBE") } + limitedStrengthPbeEncryptionMethods = pbeEncryptionMethods.findAll { !it.isUnlimitedStrength() } + } + + @Before + public void setUp() throws Exception { + } + + @After + public void tearDown() throws Exception { + + } + + private static Cipher getLegacyCipher(String password, byte[] salt, String algorithm) { + try { + final PBEKeySpec pbeKeySpec = new PBEKeySpec(password.toCharArray()); + final SecretKeyFactory factory = SecretKeyFactory.getInstance(algorithm, PROVIDER_NAME); + SecretKey tempKey = factory.generateSecret(pbeKeySpec); + + final PBEParameterSpec parameterSpec = new PBEParameterSpec(salt, ITERATION_COUNT); + Cipher cipher = Cipher.getInstance(algorithm, PROVIDER_NAME); + cipher.init(Cipher.ENCRYPT_MODE, tempKey, parameterSpec); + return cipher; + } catch (Exception e) { + logger.error("Error generating legacy cipher", e); + fail(e.getMessage()); + } + + return null; + } + + @Test + public void testGetCipherShouldBeInternallyConsistent() throws Exception { + // Arrange + NiFiLegacyCipherProvider cipherProvider = new NiFiLegacyCipherProvider(); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex("aabbccddeeff0011".toCharArray()); + + final String plaintext = "This is a plaintext message."; + + // Act + for (EncryptionMethod em : limitedStrengthPbeEncryptionMethods) { + logger.info("Using algorithm: {}", em.getAlgorithm()); + + if (!CipherUtility.passwordLengthIsValidForAlgorithmOnLimitedStrengthCrypto(PASSWORD.length(), em)) { + logger.warn("This test is skipped because the password length exceeds the undocumented limit BouncyCastle imposes on a JVM with limited strength crypto policies") + continue + } + + // Initialize a cipher for encryption + Cipher cipher = cipherProvider.getCipher(em, PASSWORD, SALT, true); + + byte[] cipherBytes = cipher.doFinal(plaintext.getBytes("UTF-8")); + logger.info("Cipher text: {} {}", Hex.encodeHexString(cipherBytes), cipherBytes.length); + + cipher = cipherProvider.getCipher(em, PASSWORD, SALT, false); + byte[] recoveredBytes = cipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + + // Assert + assert plaintext.equals(recovered); + } + } + + @Test + public void testGetCipherWithUnlimitedStrengthShouldBeInternallyConsistent() throws Exception { + // Arrange + Assume.assumeTrue("Test is being skipped due to this JVM lacking JCE Unlimited Strength Jurisdiction Policy file.", + PasswordBasedEncryptor.supportsUnlimitedStrength()); + + NiFiLegacyCipherProvider cipherProvider = new NiFiLegacyCipherProvider(); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex("aabbccddeeff0011".toCharArray()); + + final String plaintext = "This is a plaintext message."; + + // Act + for (EncryptionMethod em : pbeEncryptionMethods) { + logger.info("Using algorithm: {}", em.getAlgorithm()); + + // Initialize a cipher for encryption + Cipher cipher = cipherProvider.getCipher(em, PASSWORD, SALT, true); + + byte[] cipherBytes = cipher.doFinal(plaintext.getBytes("UTF-8")); + logger.info("Cipher text: {} {}", Hex.encodeHexString(cipherBytes), cipherBytes.length); + + cipher = cipherProvider.getCipher(em, PASSWORD, SALT, false); + byte[] recoveredBytes = cipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + + // Assert + assert plaintext.equals(recovered); + } + } + + @Test + public void testGetCipherShouldSupportLegacyCode() throws Exception { + // Arrange + NiFiLegacyCipherProvider cipherProvider = new NiFiLegacyCipherProvider(); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex("0011223344556677".toCharArray()); + + final String plaintext = "This is a plaintext message."; + + // Act + for (EncryptionMethod em : limitedStrengthPbeEncryptionMethods) { + logger.info("Using algorithm: {}", em.getAlgorithm()); + + if (!CipherUtility.passwordLengthIsValidForAlgorithmOnLimitedStrengthCrypto(PASSWORD.length(), em)) { + logger.warn("This test is skipped because the password length exceeds the undocumented limit BouncyCastle imposes on a JVM with limited strength crypto policies") + continue + } + + // Initialize a legacy cipher for encryption + Cipher legacyCipher = getLegacyCipher(PASSWORD, SALT, em.getAlgorithm()); + + byte[] cipherBytes = legacyCipher.doFinal(plaintext.getBytes("UTF-8")); + logger.info("Cipher text: {} {}", Hex.encodeHexString(cipherBytes), cipherBytes.length); + + Cipher providedCipher = cipherProvider.getCipher(em, PASSWORD, SALT, false); + byte[] recoveredBytes = providedCipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + + // Assert + assert plaintext.equals(recovered); + } + } + + @Test + public void testGetCipherWithoutSaltShouldSupportLegacyCode() throws Exception { + // Arrange + NiFiLegacyCipherProvider cipherProvider = new NiFiLegacyCipherProvider(); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = new byte[0]; + + final String plaintext = "This is a plaintext message."; + + // Act + for (EncryptionMethod em : limitedStrengthPbeEncryptionMethods) { + logger.info("Using algorithm: {}", em.getAlgorithm()); + + if (!CipherUtility.passwordLengthIsValidForAlgorithmOnLimitedStrengthCrypto(PASSWORD.length(), em)) { + logger.warn("This test is skipped because the password length exceeds the undocumented limit BouncyCastle imposes on a JVM with limited strength crypto policies") + continue + } + + // Initialize a legacy cipher for encryption + Cipher legacyCipher = getLegacyCipher(PASSWORD, SALT, em.getAlgorithm()); + + byte[] cipherBytes = legacyCipher.doFinal(plaintext.getBytes("UTF-8")); + logger.info("Cipher text: {} {}", Hex.encodeHexString(cipherBytes), cipherBytes.length); + + Cipher providedCipher = cipherProvider.getCipher(em, PASSWORD, false); + byte[] recoveredBytes = providedCipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + + // Assert + assert plaintext.equals(recovered); + } + } + + @Test + public void testGetCipherShouldIgnoreKeyLength() throws Exception { + // Arrange + NiFiLegacyCipherProvider cipherProvider = new NiFiLegacyCipherProvider(); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex("aabbccddeeff0011".toCharArray()); + + final String plaintext = "This is a plaintext message."; + + final def KEY_LENGTHS = [-1, 40, 64, 128, 192, 256] + + // Initialize a cipher for encryption + EncryptionMethod encryptionMethod = EncryptionMethod.MD5_128AES + final Cipher cipher128 = cipherProvider.getCipher(encryptionMethod, PASSWORD, SALT, true); + byte[] cipherBytes = cipher128.doFinal(plaintext.getBytes("UTF-8")); + logger.info("Cipher text: {} {}", Hex.encodeHexString(cipherBytes), cipherBytes.length); + + // Act + KEY_LENGTHS.each { int keyLength -> + logger.info("Decrypting with 'requested' key length: ${keyLength}") + + Cipher cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, SALT, keyLength, false); + byte[] recoveredBytes = cipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + + // Assert + assert plaintext.equals(recovered); + } + } + + /** + * This test determines for each PBE encryption algorithm if it actually requires the JCE unlimited strength jurisdiction policies to be installed. + * Even some algorithms that use 128-bit keys (which should be allowed on all systems) throw exceptions because BouncyCastle derives the key + * from the password using a long digest result at the time of key length checking. + * @throws IOException + */ + @Test + public void testShouldDetermineDependenceOnUnlimitedStrengthCrypto() throws IOException { + def encryptionMethods = EncryptionMethod.values().findAll { it.algorithm.startsWith("PBE") } + + boolean unlimitedCryptoSupported = PasswordBasedEncryptor.supportsUnlimitedStrength() + logger.info("This JVM supports unlimited strength crypto: ${unlimitedCryptoSupported}") + + def longestSupportedPasswordByEM = [:] + + encryptionMethods.each { EncryptionMethod encryptionMethod -> + logger.info("Attempting ${encryptionMethod.name()} (${encryptionMethod.algorithm}) which claims unlimited strength required: ${encryptionMethod.unlimitedStrength}") + + (1..20).find { int length -> + String password = "x" * length + + try { + NiFiLegacyCipherProvider cipherProvider = new NiFiLegacyCipherProvider(); + Cipher cipher = cipherProvider.getCipher(encryptionMethod, password, true) + return false + } catch (Exception e) { + logger.error("Unable to create the cipher with ${encryptionMethod.algorithm} and password ${password} (${password.length()}) due to ${e.getMessage()}") + if (!longestSupportedPasswordByEM.containsKey(encryptionMethod)) { + longestSupportedPasswordByEM.put(encryptionMethod, password.length() - 1) + } + return true + } + } + logger.info("\n") + } + + logger.info("Longest supported password by encryption method:") + longestSupportedPasswordByEM.each { EncryptionMethod encryptionMethod, int length -> + logger.info("\t${encryptionMethod.algorithm}\t${length}") + } + } +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/nifi/blob/498b5023/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/OpenSSLPKCS5CipherProviderGroovyTest.groovy ---------------------------------------------------------------------- diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/OpenSSLPKCS5CipherProviderGroovyTest.groovy b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/OpenSSLPKCS5CipherProviderGroovyTest.groovy new file mode 100644 index 0000000..31cbd5a --- /dev/null +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/util/crypto/OpenSSLPKCS5CipherProviderGroovyTest.groovy @@ -0,0 +1,319 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.nifi.processors.standard.util.crypto + +import org.apache.commons.codec.binary.Hex +import org.apache.nifi.security.util.EncryptionMethod +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.junit.* +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.PBEParameterSpec +import java.security.Security + +import static groovy.test.GroovyAssert.shouldFail +import static org.junit.Assert.fail + +@RunWith(JUnit4.class) +public class OpenSSLPKCS5CipherProviderGroovyTest { + private static final Logger logger = LoggerFactory.getLogger(OpenSSLPKCS5CipherProviderGroovyTest.class); + + private static List<EncryptionMethod> pbeEncryptionMethods = new ArrayList<>(); + private static List<EncryptionMethod> limitedStrengthPbeEncryptionMethods = new ArrayList<>(); + + private static final String PROVIDER_NAME = "BC"; + private static final int ITERATION_COUNT = 0; + + @BeforeClass + public static void setUpOnce() throws Exception { + Security.addProvider(new BouncyCastleProvider()); + + pbeEncryptionMethods = EncryptionMethod.values().findAll { it.algorithm.toUpperCase().startsWith("PBE") } + limitedStrengthPbeEncryptionMethods = pbeEncryptionMethods.findAll { !it.isUnlimitedStrength() } + } + + @Before + public void setUp() throws Exception { + } + + @After + public void tearDown() throws Exception { + + } + + private static Cipher getLegacyCipher(String password, byte[] salt, String algorithm) { + try { + final PBEKeySpec pbeKeySpec = new PBEKeySpec(password.toCharArray()); + final SecretKeyFactory factory = SecretKeyFactory.getInstance(algorithm, PROVIDER_NAME); + SecretKey tempKey = factory.generateSecret(pbeKeySpec); + + final PBEParameterSpec parameterSpec = new PBEParameterSpec(salt, ITERATION_COUNT); + Cipher cipher = Cipher.getInstance(algorithm, PROVIDER_NAME); + cipher.init(Cipher.ENCRYPT_MODE, tempKey, parameterSpec); + return cipher; + } catch (Exception e) { + logger.error("Error generating legacy cipher", e); + fail(e.getMessage()); + } + + return null; + } + + @Test + public void testGetCipherShouldBeInternallyConsistent() throws Exception { + // Arrange + OpenSSLPKCS5CipherProvider cipherProvider = new OpenSSLPKCS5CipherProvider(); + + final String PASSWORD = "short"; + final byte[] SALT = Hex.decodeHex("aabbccddeeff0011".toCharArray()); + + final String plaintext = "This is a plaintext message."; + + // Act + for (EncryptionMethod em : limitedStrengthPbeEncryptionMethods) { + logger.info("Using algorithm: {}", em.getAlgorithm()); + + if (!CipherUtility.passwordLengthIsValidForAlgorithmOnLimitedStrengthCrypto(PASSWORD.length(), em)) { + logger.warn("This test is skipped because the password length exceeds the undocumented limit BouncyCastle imposes on a JVM with limited strength crypto policies") + continue + } + + // Initialize a cipher for encryption + Cipher cipher = cipherProvider.getCipher(em, PASSWORD, SALT, true); + + byte[] cipherBytes = cipher.doFinal(plaintext.getBytes("UTF-8")); + logger.info("Cipher text: {} {}", Hex.encodeHexString(cipherBytes), cipherBytes.length); + + cipher = cipherProvider.getCipher(em, PASSWORD, SALT, false); + byte[] recoveredBytes = cipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + + // Assert + assert plaintext.equals(recovered); + } + } + + @Test + public void testGetCipherWithUnlimitedStrengthShouldBeInternallyConsistent() throws Exception { + // Arrange + Assume.assumeTrue("Test is being skipped due to this JVM lacking JCE Unlimited Strength Jurisdiction Policy file.", + PasswordBasedEncryptor.supportsUnlimitedStrength()); + + OpenSSLPKCS5CipherProvider cipherProvider = new OpenSSLPKCS5CipherProvider(); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex("aabbccddeeff0011".toCharArray()); + + final String plaintext = "This is a plaintext message."; + + // Act + for (EncryptionMethod em : pbeEncryptionMethods) { + logger.info("Using algorithm: {}", em.getAlgorithm()); + + // Initialize a cipher for encryption + Cipher cipher = cipherProvider.getCipher(em, PASSWORD, SALT, true); + + byte[] cipherBytes = cipher.doFinal(plaintext.getBytes("UTF-8")); + logger.info("Cipher text: {} {}", Hex.encodeHexString(cipherBytes), cipherBytes.length); + + cipher = cipherProvider.getCipher(em, PASSWORD, SALT, false); + byte[] recoveredBytes = cipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + + // Assert + assert plaintext.equals(recovered); + } + } + + @Test + public void testGetCipherShouldSupportLegacyCode() throws Exception { + // Arrange + OpenSSLPKCS5CipherProvider cipherProvider = new OpenSSLPKCS5CipherProvider(); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex("0011223344556677".toCharArray()); + + final String plaintext = "This is a plaintext message."; + + // Act + for (EncryptionMethod em : limitedStrengthPbeEncryptionMethods) { + logger.info("Using algorithm: {}", em.getAlgorithm()); + + if (!CipherUtility.passwordLengthIsValidForAlgorithmOnLimitedStrengthCrypto(PASSWORD.length(), em)) { + logger.warn("This test is skipped because the password length exceeds the undocumented limit BouncyCastle imposes on a JVM with limited strength crypto policies") + continue + } + + // Initialize a legacy cipher for encryption + Cipher legacyCipher = getLegacyCipher(PASSWORD, SALT, em.getAlgorithm()); + + byte[] cipherBytes = legacyCipher.doFinal(plaintext.getBytes("UTF-8")); + logger.info("Cipher text: {} {}", Hex.encodeHexString(cipherBytes), cipherBytes.length); + + Cipher providedCipher = cipherProvider.getCipher(em, PASSWORD, SALT, false); + byte[] recoveredBytes = providedCipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + + // Assert + assert plaintext.equals(recovered); + } + } + + @Test + public void testGetCipherWithoutSaltShouldSupportLegacyCode() throws Exception { + // Arrange + OpenSSLPKCS5CipherProvider cipherProvider = new OpenSSLPKCS5CipherProvider(); + + final String PASSWORD = "short"; + final byte[] SALT = new byte[0]; + + final String plaintext = "This is a plaintext message."; + + // Act + for (EncryptionMethod em : limitedStrengthPbeEncryptionMethods) { + logger.info("Using algorithm: {}", em.getAlgorithm()); + + if (!CipherUtility.passwordLengthIsValidForAlgorithmOnLimitedStrengthCrypto(PASSWORD.length(), em)) { + logger.warn("This test is skipped because the password length exceeds the undocumented limit BouncyCastle imposes on a JVM with limited strength crypto policies") + continue + } + + // Initialize a legacy cipher for encryption + Cipher legacyCipher = getLegacyCipher(PASSWORD, SALT, em.getAlgorithm()); + + byte[] cipherBytes = legacyCipher.doFinal(plaintext.getBytes("UTF-8")); + logger.info("Cipher text: {} {}", Hex.encodeHexString(cipherBytes), cipherBytes.length); + + Cipher providedCipher = cipherProvider.getCipher(em, PASSWORD, false); + byte[] recoveredBytes = providedCipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + + // Assert + assert plaintext.equals(recovered); + } + } + + @Test + public void testGetCipherShouldIgnoreKeyLength() throws Exception { + // Arrange + OpenSSLPKCS5CipherProvider cipherProvider = new OpenSSLPKCS5CipherProvider(); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex("aabbccddeeff0011".toCharArray()); + + final String plaintext = "This is a plaintext message."; + + final def KEY_LENGTHS = [-1, 40, 64, 128, 192, 256] + + // Initialize a cipher for encryption + EncryptionMethod encryptionMethod = EncryptionMethod.MD5_128AES + final Cipher cipher128 = cipherProvider.getCipher(encryptionMethod, PASSWORD, SALT, true); + byte[] cipherBytes = cipher128.doFinal(plaintext.getBytes("UTF-8")); + logger.info("Cipher text: {} {}", Hex.encodeHexString(cipherBytes), cipherBytes.length); + + // Act + KEY_LENGTHS.each { int keyLength -> + logger.info("Decrypting with 'requested' key length: ${keyLength}") + + Cipher cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, SALT, keyLength, false); + byte[] recoveredBytes = cipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + + // Assert + assert plaintext.equals(recovered); + } + } + + @Test + public void testGetCipherShouldRequireEncryptionMethod() throws Exception { + // Arrange + OpenSSLPKCS5CipherProvider cipherProvider = new OpenSSLPKCS5CipherProvider(); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex("0011223344556677".toCharArray()); + + // Act + logger.info("Using algorithm: null"); + + def msg = shouldFail(IllegalArgumentException) { + Cipher providedCipher = cipherProvider.getCipher(null, PASSWORD, SALT, false); + } + + // Assert + assert msg =~ "The encryption method must be specified" + } + + @Test + public void testGetCipherShouldRequirePassword() throws Exception { + // Arrange + OpenSSLPKCS5CipherProvider cipherProvider = new OpenSSLPKCS5CipherProvider(); + + final byte[] SALT = Hex.decodeHex("0011223344556677".toCharArray()); + EncryptionMethod encryptionMethod = EncryptionMethod.MD5_128AES + + // Act + logger.info("Using algorithm: ${encryptionMethod}"); + + def msg = shouldFail(IllegalArgumentException) { + Cipher providedCipher = cipherProvider.getCipher(encryptionMethod, "", SALT, false); + } + + // Assert + assert msg =~ "Encryption with an empty password is not supported" + } + + @Test + public void testGetCipherShouldValidateSaltLength() throws Exception { + // Arrange + OpenSSLPKCS5CipherProvider cipherProvider = new OpenSSLPKCS5CipherProvider(); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex("00112233445566".toCharArray()); + EncryptionMethod encryptionMethod = EncryptionMethod.MD5_128AES + + // Act + logger.info("Using algorithm: ${encryptionMethod}"); + + def msg = shouldFail(IllegalArgumentException) { + Cipher providedCipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, SALT, false); + } + + // Assert + assert msg =~ "Salt must be 8 bytes US-ASCII encoded" + } + + @Test + public void testGenerateSaltShouldProvideValidSalt() throws Exception { + // Arrange + PBECipherProvider cipherProvider = new OpenSSLPKCS5CipherProvider() + + // Act + byte[] salt = cipherProvider.generateSalt() + logger.info("Checking salt ${Hex.encodeHexString(salt)}") + + // Assert + assert salt.length == cipherProvider.getDefaultSaltLength() + assert salt != [(0x00 as byte) * cipherProvider.defaultSaltLength] + } +} \ No newline at end of file
