Return-Path: X-Original-To: archive-asf-public-internal@cust-asf2.ponee.io Delivered-To: archive-asf-public-internal@cust-asf2.ponee.io Received: from cust-asf.ponee.io (cust-asf.ponee.io [163.172.22.183]) by cust-asf2.ponee.io (Postfix) with ESMTP id 9471C200CA4 for ; Tue, 2 May 2017 19:27:29 +0200 (CEST) Received: by cust-asf.ponee.io (Postfix) id 92FD8160B9D; Tue, 2 May 2017 17:27:29 +0000 (UTC) Delivered-To: archive-asf-public@cust-asf.ponee.io Received: from mail.apache.org (hermes.apache.org [140.211.11.3]) by cust-asf.ponee.io (Postfix) with SMTP id 32FDD160BAB for ; Tue, 2 May 2017 19:27:27 +0200 (CEST) Received: (qmail 29607 invoked by uid 500); 2 May 2017 17:27:26 -0000 Mailing-List: contact commits-help@nifi.apache.org; run by ezmlm Precedence: bulk List-Help: List-Unsubscribe: List-Post: List-Id: Reply-To: dev@nifi.apache.org Delivered-To: mailing list commits@nifi.apache.org Received: (qmail 29516 invoked by uid 99); 2 May 2017 17:27:26 -0000 Received: from git1-us-west.apache.org (HELO git1-us-west.apache.org) (140.211.11.23) by apache.org (qpsmtpd/0.29) with ESMTP; Tue, 02 May 2017 17:27:26 +0000 Received: by git1-us-west.apache.org (ASF Mail Server at git1-us-west.apache.org, from userid 33) id 3BBFDDFE8F; Tue, 2 May 2017 17:27:26 +0000 (UTC) Content-Type: text/plain; charset="us-ascii" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit From: alopresto@apache.org To: commits@nifi.apache.org Date: Tue, 02 May 2017 17:27:27 -0000 Message-Id: <10939f1189f844b285a235ec7b32c7b8@git.apache.org> In-Reply-To: References: X-Mailer: ASF-Git Admin Mailer Subject: [02/13] nifi git commit: NIFI-3594 Implemented encrypted provenance repository. Added src/test/resources/logback-test.xml files resetting log level from DEBUG (in nifi-data-provenance-utils) to WARN because later tests depend on MockComponentLog recordin archived-at: Tue, 02 May 2017 17:27:29 -0000 http://git-wip-us.apache.org/repos/asf/nifi/blob/7d242076/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/PBKDF2CipherProviderGroovyTest.groovy ---------------------------------------------------------------------- diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/PBKDF2CipherProviderGroovyTest.groovy b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/PBKDF2CipherProviderGroovyTest.groovy new file mode 100644 index 0000000..51a8177 --- /dev/null +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/PBKDF2CipherProviderGroovyTest.groovy @@ -0,0 +1,545 @@ +/* + * 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.security.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.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.slf4j.LoggerFactory + +import javax.crypto.Cipher +import java.security.Security + +import static groovy.test.GroovyAssert.shouldFail +import static org.junit.Assert.assertTrue + +@RunWith(JUnit4.class) +public class PBKDF2CipherProviderGroovyTest { + private static final Logger logger = LoggerFactory.getLogger(PBKDF2CipherProviderGroovyTest.class); + + private static List strongKDFEncryptionMethods + + public static final String MICROBENCHMARK = "microbenchmark" + private static final int DEFAULT_KEY_LENGTH = 128; + private static final int TEST_ITERATION_COUNT = 1000 + private final String DEFAULT_PRF = "SHA-512" + private final String SALT_HEX = "0123456789ABCDEFFEDCBA9876543210" + private final String IV_HEX = "01" * 16 + private static ArrayList 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 PBKDF2CipherProvider(DEFAULT_PRF, TEST_ITERATION_COUNT); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex(SALT_HEX 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, 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 testGetCipherShouldRejectInvalidIV() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new PBKDF2CipherProvider(DEFAULT_PRF, TEST_ITERATION_COUNT) + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex(SALT_HEX as char[]); + 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, PASSWORD, SALT, badIV, DEFAULT_KEY_LENGTH, true) + + // Decrypt should fail + def msg = shouldFail(IllegalArgumentException) { + cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, SALT, badIV, DEFAULT_KEY_LENGTH, false) + } + + // Assert + assert msg =~ "Cannot decrypt without a valid IV" + } + } + + @Test + public void testGetCipherWithExternalIVShouldBeInternallyConsistent() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new PBKDF2CipherProvider(DEFAULT_PRF, TEST_ITERATION_COUNT); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex(SALT_HEX as char[]); + final byte[] IV = Hex.decodeHex(IV_HEX 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 PBKDF2CipherProvider(DEFAULT_PRF, TEST_ITERATION_COUNT); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex(SALT_HEX as char[]); + + 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 testShouldRejectEmptyPRF() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex(SALT_HEX as char[]); + final byte[] IV = Hex.decodeHex(IV_HEX as char[]); + + final String plaintext = "This is a plaintext message."; + final EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + String prf = "" + + // Act + logger.info("Using PRF ${prf}") + def msg = shouldFail(IllegalArgumentException) { + cipherProvider = new PBKDF2CipherProvider(prf, TEST_ITERATION_COUNT); + } + + // Assert + assert msg =~ "Cannot resolve empty PRF" + } + + @Test + public void testShouldResolveDefaultPRF() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex(SALT_HEX as char[]); + final byte[] IV = Hex.decodeHex(IV_HEX as char[]); + + final String plaintext = "This is a plaintext message."; + final EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + + final PBKDF2CipherProvider SHA512_PROVIDER = new PBKDF2CipherProvider(DEFAULT_PRF, TEST_ITERATION_COUNT) + + String prf = "sha768" + logger.info("Using ${prf}") + + // Act + cipherProvider = new PBKDF2CipherProvider(prf, TEST_ITERATION_COUNT); + logger.info("Resolved PRF to ${cipherProvider.getPRFName()}") + logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()}"); + + // Initialize a cipher for encryption + Cipher cipher = cipherProvider.getCipher(encryptionMethod, 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 = SHA512_PROVIDER.getCipher(encryptionMethod, 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 testShouldResolveVariousPRFs() throws Exception { + // Arrange + final List PRFS = ["SHA-1", "MD5", "SHA-256", "SHA-384", "SHA-512"] + RandomIVPBECipherProvider cipherProvider + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex(SALT_HEX as char[]); + final byte[] IV = Hex.decodeHex(IV_HEX as char[]); + + final String plaintext = "This is a plaintext message."; + final EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + + // Act + PRFS.each { String prf -> + logger.info("Using ${prf}") + cipherProvider = new PBKDF2CipherProvider(prf, TEST_ITERATION_COUNT); + logger.info("Resolved PRF to ${cipherProvider.getPRFName()}") + + logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()}"); + + // Initialize a cipher for encryption + Cipher cipher = cipherProvider.getCipher(encryptionMethod, 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(encryptionMethod, 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 testGetCipherShouldSupportExternalCompatibility() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new PBKDF2CipherProvider("SHA-256", TEST_ITERATION_COUNT); + + final String PLAINTEXT = "This is a plaintext message."; + final String PASSWORD = "thisIsABadPassword"; + + // These values can be generated by running `$ ./openssl_pbkdf2.rb` in the terminal + final byte[] SALT = Hex.decodeHex("ae2481bee3d8b5d5b732bf464ea2ff01" as char[]); + final byte[] IV = Hex.decodeHex("26db997dcd18472efd74dabe5ff36853" as char[]); + + final String CIPHER_TEXT = "92edbabae06add6275a1d64815755a9ba52afc96e2c1a316d3abbe1826e96f6c" + byte[] cipherBytes = Hex.decodeHex(CIPHER_TEXT as char[]) + + 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, 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 testGetCipherForDecryptShouldRequireIV() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new PBKDF2CipherProvider(DEFAULT_PRF, TEST_ITERATION_COUNT); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex(SALT_HEX as char[]); + final byte[] IV = Hex.decodeHex(IV_HEX 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 testGetCipherShouldRejectInvalidSalt() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new PBKDF2CipherProvider(DEFAULT_PRF, TEST_ITERATION_COUNT) + + final String PASSWORD = "thisIsABadPassword"; + + final def INVALID_SALTS = ['pbkdf2', '$3a$11$', 'x', '$2a$10$', '', null] + + 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 at least 16 bytes\\. To generate a salt, use PBKDF2CipherProvider#generateSalt" + } + } + + @Test + public void testGetCipherShouldAcceptValidKeyLengths() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new PBKDF2CipherProvider(DEFAULT_PRF, TEST_ITERATION_COUNT) + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex(SALT_HEX as char[]) + final byte[] IV = Hex.decodeHex(IV_HEX as char[]); + + final String PLAINTEXT = "This is a plaintext message."; + + // Currently only AES ciphers are compatible with PBKDF2, 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 PBKDF2CipherProvider(DEFAULT_PRF, TEST_ITERATION_COUNT); + + final String PASSWORD = "shortPassword"; + final byte[] SALT = Hex.decodeHex(SALT_HEX as char[]) + final byte[] IV = Hex.decodeHex(IV_HEX as char[]); + + // Currently only AES ciphers are compatible with PBKDF2, so redundant to test all algorithms + final def VALID_KEY_LENGTHS = [-1, 40, 64, 112, 512] + 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 + 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" + } + } + + @Ignore("This test can be run on a specific machine to evaluate if the default iteration count is sufficient") + @Test + public void testDefaultConstructorShouldProvideStrongIterationCount() { + // Arrange + RandomIVPBECipherProvider cipherProvider = new PBKDF2CipherProvider(); + + // 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 iteration count to reach 500 ms + int minimumIterationCount = calculateMinimumIterationCount() + logger.info("Determined minimum safe iteration count to be ${minimumIterationCount}") + + // Act + int iterationCount = cipherProvider.getIterationCount() + logger.info("Default iteration count ${iterationCount}") + + // Assert + assertTrue("The default iteration count for PBKDF2CipherProvider is too weak. Please update the default value to a stronger level.", iterationCount >= minimumIterationCount) + } + + /** + * Returns the iteration count required for a derivation to exceed 500 ms on this machine using the default PRF. + * Code adapted from http://security.stackexchange.com/questions/17207/recommended-of-rounds-for-bcrypt + * + * @return the minimum iteration count + */ + private static int calculateMinimumIterationCount() { + // High start-up cost, so run multiple times for better benchmarking + final int RUNS = 10 + + // Benchmark using an iteration count of 10k + int iterationCount = 10_000 + + final byte[] SALT = [0x00 as byte] * 16 + final byte[] IV = [0x01 as byte] * 16 + + String defaultPrf = new PBKDF2CipherProvider().getPRFName() + RandomIVPBECipherProvider cipherProvider = new PBKDF2CipherProvider(defaultPrf, iterationCount) + + // Run once to prime the system + double duration = time { + Cipher cipher = cipherProvider.getCipher(EncryptionMethod.AES_CBC, MICROBENCHMARK, SALT, IV, DEFAULT_KEY_LENGTH, false) + } + logger.info("First run of iteration count ${iterationCount} took ${duration} ms (ignored)") + + def durations = [] + + RUNS.times { int i -> + duration = time { + // Use encrypt mode with provided salt and IV to minimize overhead during benchmark call + Cipher cipher = cipherProvider.getCipher(EncryptionMethod.AES_CBC, "${MICROBENCHMARK}${i}", SALT, IV, DEFAULT_KEY_LENGTH, false) + } + logger.info("Iteration count ${iterationCount} took ${duration} ms") + durations << duration + } + + duration = durations.sum() / durations.size() + logger.info("Iteration count ${iterationCount} averaged ${duration} ms") + + // Keep increasing iteration count until the estimated duration is over 500 ms + while (duration < 500) { + iterationCount *= 2 + duration *= 2 + } + + logger.info("Returning iteration count ${iterationCount} for ${duration} ms") + + return iterationCount + } + + private static double time(Closure c) { + long start = System.nanoTime() + c.call() + long end = System.nanoTime() + return (end - start) / 1_000_000.0 + } + + @Test + public void testGenerateSaltShouldProvideValidSalt() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new PBKDF2CipherProvider(DEFAULT_PRF, TEST_ITERATION_COUNT) + + // Act + byte[] salt = cipherProvider.generateSalt() + logger.info("Checking salt ${Hex.encodeHexString(salt)}") + + // Assert + assert salt.length == 16 + assert salt != [(0x00 as byte) * 16] + } +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/nifi/blob/7d242076/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/PasswordBasedEncryptorGroovyTest.groovy ---------------------------------------------------------------------- diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/PasswordBasedEncryptorGroovyTest.groovy b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/PasswordBasedEncryptorGroovyTest.groovy new file mode 100644 index 0000000..7008381 --- /dev/null +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/PasswordBasedEncryptorGroovyTest.groovy @@ -0,0 +1,225 @@ +/* + * 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.security.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.apache.nifi.security.util.KeyDerivationFunction +import org.apache.nifi.stream.io.ByteArrayOutputStream +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.Test +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +import javax.crypto.Cipher +import java.security.Security + +public class PasswordBasedEncryptorGroovyTest { + private static final Logger logger = LoggerFactory.getLogger(PasswordBasedEncryptorGroovyTest.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}/salted_128_raw.asc") + + private static final String PASSWORD = "thisIsABadPassword" + private static final String LEGACY_PASSWORD = "Hello, World!" + + @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")) + + String shortPassword = "short" + + def encryptionMethodsAndKdfs = [ + (KeyDerivationFunction.OPENSSL_EVP_BYTES_TO_KEY): EncryptionMethod.MD5_128AES, + (KeyDerivationFunction.NIFI_LEGACY) : EncryptionMethod.MD5_128AES, + (KeyDerivationFunction.BCRYPT) : EncryptionMethod.AES_CBC, + (KeyDerivationFunction.SCRYPT) : EncryptionMethod.AES_CBC, + (KeyDerivationFunction.PBKDF2) : EncryptionMethod.AES_CBC + ] + + // Act + encryptionMethodsAndKdfs.each { KeyDerivationFunction kdf, EncryptionMethod encryptionMethod -> + OutputStream cipherStream = new ByteArrayOutputStream() + OutputStream recoveredStream = new ByteArrayOutputStream() + + logger.info("Using ${kdf.name} and ${encryptionMethod.name()}") + PasswordBasedEncryptor encryptor = new PasswordBasedEncryptor(encryptionMethod, shortPassword.toCharArray(), kdf) + + 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) + + // This is necessary to run multiple iterations + plainStream.reset() + } + } + + @Test + public void testShouldDecryptLegacyOpenSSLSaltedCipherText() throws Exception { + // Arrange + Assume.assumeTrue("Skipping test because unlimited strength crypto policy not installed", PasswordBasedEncryptor.supportsUnlimitedStrength()) + + final String PLAINTEXT = new File("${TEST_RESOURCES_PREFIX}/plain.txt").text + logger.info("Plaintext: {}", PLAINTEXT) + byte[] cipherBytes = new File("${TEST_RESOURCES_PREFIX}/salted_128_raw.enc").bytes + InputStream cipherStream = new ByteArrayInputStream(cipherBytes) + OutputStream recoveredStream = new ByteArrayOutputStream() + + final EncryptionMethod encryptionMethod = EncryptionMethod.MD5_128AES + final KeyDerivationFunction kdf = KeyDerivationFunction.OPENSSL_EVP_BYTES_TO_KEY + + PasswordBasedEncryptor encryptor = new PasswordBasedEncryptor(encryptionMethod, PASSWORD.toCharArray(), kdf) + + 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) + } + + @Test + public void testShouldDecryptLegacyOpenSSLUnsaltedCipherText() throws Exception { + // Arrange + Assume.assumeTrue("Skipping test because unlimited strength crypto policy not installed", PasswordBasedEncryptor.supportsUnlimitedStrength()) + + 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 + InputStream cipherStream = new ByteArrayInputStream(cipherBytes) + OutputStream recoveredStream = new ByteArrayOutputStream() + + final EncryptionMethod encryptionMethod = EncryptionMethod.MD5_128AES + final KeyDerivationFunction kdf = KeyDerivationFunction.OPENSSL_EVP_BYTES_TO_KEY + + PasswordBasedEncryptor encryptor = new PasswordBasedEncryptor(encryptionMethod, PASSWORD.toCharArray(), kdf) + + 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) + } + + @Test + public void testShouldDecryptNiFiLegacySaltedCipherTextWithVariableSaltLength() throws Exception { + // Arrange + final String PLAINTEXT = new File("${TEST_RESOURCES_PREFIX}/plain.txt").text + logger.info("Plaintext: {}", PLAINTEXT) + + final String PASSWORD = "short" + logger.info("Password: ${PASSWORD}") + + /* The old NiFi legacy KDF code checked the algorithm block size and used it for the salt length. + If the block size was not available, it defaulted to 8 bytes based on the default salt size. */ + + def pbeEncryptionMethods = EncryptionMethod.values().findAll { it.algorithm.startsWith("PBE") } + def encryptionMethodsByBlockSize = pbeEncryptionMethods.groupBy { + Cipher cipher = Cipher.getInstance(it.algorithm, it.provider) + cipher.getBlockSize() + } + + logger.info("Grouped algorithms by block size: ${encryptionMethodsByBlockSize.collectEntries { k, v -> [k, v*.algorithm] }}") + + encryptionMethodsByBlockSize.each { int blockSize, List encryptionMethods -> + encryptionMethods.each { EncryptionMethod encryptionMethod -> + final int EXPECTED_SALT_SIZE = (blockSize > 0) ? blockSize : 8 + logger.info("Testing ${encryptionMethod.algorithm} with expected salt size ${EXPECTED_SALT_SIZE}") + + def legacySaltHex = "aa" * EXPECTED_SALT_SIZE + byte[] legacySalt = Hex.decodeHex(legacySaltHex as char[]) + logger.info("Generated legacy salt ${legacySaltHex} (${legacySalt.length})") + + // Act + + // Encrypt using the raw legacy code + NiFiLegacyCipherProvider legacyCipherProvider = new NiFiLegacyCipherProvider() + Cipher legacyCipher = legacyCipherProvider.getCipher(encryptionMethod, PASSWORD, legacySalt, true) + byte[] cipherBytes = legacyCipher.doFinal(PLAINTEXT.bytes) + logger.info("Cipher bytes: ${Hex.encodeHexString(cipherBytes)}") + + byte[] completeCipherStreamBytes = CipherUtility.concatBytes(legacySalt, cipherBytes) + logger.info("Complete cipher stream: ${Hex.encodeHexString(completeCipherStreamBytes)}") + + InputStream cipherStream = new ByteArrayInputStream(completeCipherStreamBytes) + OutputStream resultStream = new ByteArrayOutputStream() + + // Now parse and decrypt using PBE encryptor + PasswordBasedEncryptor decryptor = new PasswordBasedEncryptor(encryptionMethod, PASSWORD as char[], KeyDerivationFunction.NIFI_LEGACY) + + StreamCallback decryptCallback = decryptor.decryptionCallback + decryptCallback.process(cipherStream, resultStream) + + logger.info("Decrypted: ${Hex.encodeHexString(resultStream.toByteArray())}") + String recovered = new String(resultStream.toByteArray()) + logger.info("Recovered: ${recovered}") + + // Assert + assert recovered == PLAINTEXT + } + } + } +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/nifi/blob/7d242076/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/ScryptCipherProviderGroovyTest.groovy ---------------------------------------------------------------------- diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/ScryptCipherProviderGroovyTest.groovy b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/ScryptCipherProviderGroovyTest.groovy new file mode 100644 index 0000000..8fce9f2 --- /dev/null +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/ScryptCipherProviderGroovyTest.groovy @@ -0,0 +1,597 @@ +/* + * 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.security.util.crypto + +import org.apache.commons.codec.binary.Base64 +import org.apache.commons.codec.binary.Hex +import org.apache.nifi.security.util.EncryptionMethod +import org.apache.nifi.security.util.crypto.scrypt.Scrypt +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.slf4j.LoggerFactory + +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import java.security.SecureRandom +import java.security.Security + +import static groovy.test.GroovyAssert.shouldFail +import static org.junit.Assert.assertTrue + +@RunWith(JUnit4.class) +public class ScryptCipherProviderGroovyTest { + private static final Logger logger = LoggerFactory.getLogger(ScryptCipherProviderGroovyTest.class); + + private static List strongKDFEncryptionMethods + + private static final int DEFAULT_KEY_LENGTH = 128; + public static final String MICROBENCHMARK = "microbenchmark" + private static ArrayList AES_KEY_LENGTHS + + RandomIVPBECipherProvider cipherProvider + + @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 { + // Very fast parameters to test for correctness rather than production values + cipherProvider = new ScryptCipherProvider(4, 1, 1) + } + + @After + public void tearDown() throws Exception { + + } + + @Test + public void testGetCipherShouldBeInternallyConsistent() throws Exception { + // Arrange + 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 + 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()); + + 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 testScryptShouldSupportExternalCompatibility() throws Exception { + // Arrange + + // Default values are N=2^14, r=8, p=1, but the provided salt will contain the parameters used + cipherProvider = new ScryptCipherProvider() + + final String PLAINTEXT = "This is a plaintext message."; + final String PASSWORD = "thisIsABadPassword" + final int DK_LEN = 128 + + // These values can be generated by running `$ ./openssl_scrypt.rb` in the terminal + final byte[] SALT = Hex.decodeHex("f5b8056ea6e66edb8d013ac432aba24a" as char[]) + logger.info("Expected salt: ${Hex.encodeHexString(SALT)}") + final byte[] IV = Hex.decodeHex("76a00f00878b8c3db314ae67804c00a1" as char[]) + + final String CIPHER_TEXT = "604188bf8e9137bc1b24a0ab01973024bc5935e9ae5fedf617bdca028c63c261" + logger.sanity("Ruby cipher text: ${CIPHER_TEXT}") + byte[] cipherBytes = Hex.decodeHex(CIPHER_TEXT as char[]) + + EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + + // Sanity check + String rubyKeyHex = "a8efbc0a709d3f89b6bb35b05fc8edf5" + logger.sanity("Using key: ${rubyKeyHex}") + logger.sanity("Using IV: ${Hex.encodeHexString(IV)}") + Cipher rubyCipher = Cipher.getInstance(encryptionMethod.algorithm, "BC") + def rubyKey = new SecretKeySpec(Hex.decodeHex(rubyKeyHex as char[]), "AES") + def ivSpec = new IvParameterSpec(IV) + rubyCipher.init(Cipher.ENCRYPT_MODE, rubyKey, ivSpec) + byte[] rubyCipherBytes = rubyCipher.doFinal(PLAINTEXT.bytes) + logger.sanity("Created cipher text: ${Hex.encodeHexString(rubyCipherBytes)}") + rubyCipher.init(Cipher.DECRYPT_MODE, rubyKey, ivSpec) + assert rubyCipher.doFinal(rubyCipherBytes) == PLAINTEXT.bytes + logger.sanity("Decrypted generated cipher text successfully") + assert rubyCipher.doFinal(cipherBytes) == PLAINTEXT.bytes + logger.sanity("Decrypted external cipher text successfully") + + // n$r$p$hex_salt_SL$hex_hash_HL + final String FULL_HASH = "400\$8\$24\$f5b8056ea6e66edb8d013ac432aba24a\$a8efbc0a709d3f89b6bb35b05fc8edf5" + logger.info("Full Hash: ${FULL_HASH}") + + def (String nStr, String rStr, String pStr, String saltHex, String hashHex) = FULL_HASH.split("\\\$") + def (n, r, p) = [nStr, rStr, pStr].collect { Integer.valueOf(it, 16) } + + logger.info("N: Hex ${nStr} -> ${n}") + logger.info("r: Hex ${rStr} -> ${r}") + logger.info("p: Hex ${pStr} -> ${p}") + logger.info("Salt: ${saltHex}") + logger.info("Hash: ${hashHex}") + + // Form Java-style salt with cost params from Ruby-style + String javaSalt = Scrypt.formatSalt(Hex.decodeHex(saltHex as char[]), n, r, p) + logger.info("Formed Java-style salt: ${javaSalt}") + + // Convert hash from hex to Base64 + String base64Hash = CipherUtility.encodeBase64NoPadding(Hex.decodeHex(hashHex as char[])) + logger.info("Converted hash from hex ${hashHex} to Base64 ${base64Hash}") + assert Hex.encodeHexString(Base64.decodeBase64(base64Hash)) == hashHex + + logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()}"); + logger.info("External cipher text: ${CIPHER_TEXT} ${cipherBytes.length}"); + + // Act + Cipher cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, javaSalt.bytes, IV, DK_LEN, 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 testGetCipherShouldHandleSaltWithoutParameters() throws Exception { + // Arrange + + // To help Groovy resolve implementation private methods not known at interface level + cipherProvider = cipherProvider as ScryptCipherProvider + + final String PASSWORD = "shortPassword"; + final byte[] SALT = new byte[cipherProvider.defaultSaltLength] + new SecureRandom().nextBytes(SALT) + + final String EXPECTED_FORMATTED_SALT = cipherProvider.formatSaltForScrypt(SALT) + logger.info("Expected salt: ${EXPECTED_FORMATTED_SALT}") + + final String plaintext = "This is a plaintext message."; + EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()}"); + + // Act + + // Initialize a cipher for encryption + Cipher cipher = cipherProvider.getCipher(encryptionMethod, 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}"); + + // Manually initialize a cipher for decrypt with the expected salt + byte[] parsedSalt = new byte[cipherProvider.defaultSaltLength] + def params = [] + cipherProvider.parseSalt(EXPECTED_FORMATTED_SALT, parsedSalt, params) + def (int n, int r, int p) = params + byte[] keyBytes = Scrypt.deriveScryptKey(PASSWORD.bytes, parsedSalt, n, r, p, DEFAULT_KEY_LENGTH) + SecretKey key = new SecretKeySpec(keyBytes, "AES") + Cipher manualCipher = Cipher.getInstance(encryptionMethod.algorithm, encryptionMethod.provider) + manualCipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv)) + byte[] recoveredBytes = manualCipher.doFinal(cipherBytes); + String recovered = new String(recoveredBytes, "UTF-8"); + logger.info("Recovered: ${recovered}") + + // Assert + assert plaintext.equals(recovered); + } + + @Test + public void testGetCipherShouldNotAcceptInvalidSalts() throws Exception { + // Arrange + final String PASSWORD = "thisIsABadPassword"; + + final def INVALID_SALTS = ['bad_sal', '$3a$11$', 'x', '$2a$10$', '$400$1$1$abcdefghijklmnopqrstuvwxyz'] + final LENGTH_MESSAGE = "The raw salt must be between 8 and 32 bytes" + + 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); + } + logger.expected(msg) + + // Assert + assert msg =~ LENGTH_MESSAGE + } + } + + @Test + public void testGetCipherShouldHandleUnformattedSalts() throws Exception { + // Arrange + final String PASSWORD = "thisIsABadPassword"; + + final def RECOVERABLE_SALTS = ['$ab$00$acbdefghijklmnopqrstuv', '$4$1$1$0123456789abcdef', '$400$1$1$abcdefghijklmnopqrstuv'] + + EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()}"); + + // Act + RECOVERABLE_SALTS.each { String salt -> + logger.info("Checking salt ${salt}") + + Cipher cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, salt.bytes, DEFAULT_KEY_LENGTH, true); + + // Assert + assert cipher + } + } + + @Test + public void testGetCipherShouldRejectEmptySalt() throws Exception { + // Arrange + final String PASSWORD = "thisIsABadPassword"; + + EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()}"); + + // Act + def 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 ScryptCipherProvider#generateSalt" + } + + @Test + public void testGetCipherForDecryptShouldRequireIV() throws Exception { + // Arrange + 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); + } + logger.expected(msg) + + // Assert + assert msg =~ "Cannot decrypt without a valid IV" + } + } + + @Test + public void testGetCipherShouldAcceptValidKeyLengths() throws Exception { + // Arrange + 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 + 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."; + + // Even though Scrypt can derive keys of arbitrary length, it will fail to validate if the underlying cipher does not support it + final def INVALID_KEY_LENGTHS = [-1, 40, 64, 112, 512] + // Currently only AES ciphers are compatible with Scrypt, so redundant to test all algorithms + 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); + } + logger.expected(msg) + + // Assert + assert msg =~ "${keyLength} is not a valid key length for AES" + } + } + + @Test + public void testScryptShouldNotAcceptInvalidPassword() { + // Arrange + String badPassword = "" + byte[] salt = [0x01 as byte] * 16 + + EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC + + // Act + def msg = shouldFail(IllegalArgumentException) { + cipherProvider.getCipher(encryptionMethod, badPassword, salt, DEFAULT_KEY_LENGTH, true) + } + + // Assert + assert msg =~ "Encryption with an empty password is not supported" + } + + @Test + public void testGenerateSaltShouldUseProvidedParameters() throws Exception { + // Arrange + RandomIVPBECipherProvider cipherProvider = new ScryptCipherProvider(8, 2, 2); + int n = cipherProvider.getN() + int r = cipherProvider.getR() + int p = cipherProvider.getP() + + // Act + final String salt = new String(cipherProvider.generateSalt()) + logger.info("Salt: ${salt}") + + // Assert + assert salt =~ "^(?i)\\\$s0\\\$[a-f0-9]{5,16}\\\$" + String params = Scrypt.encodeParams(n, r, p) + assert salt.contains("\$${params}\$") + } + + @Test + public void testShouldParseSalt() throws Exception { + // Arrange + cipherProvider = cipherProvider as ScryptCipherProvider + + final byte[] EXPECTED_RAW_SALT = Hex.decodeHex("f5b8056ea6e66edb8d013ac432aba24a" as char[]) + final int EXPECTED_N = 1024 + final int EXPECTED_R = 8 + final int EXPECTED_P = 36 + + final String FORMATTED_SALT = "\$s0\$a0824\$9bgFbqbmbtuNATrEMquiSg" + logger.info("Using salt: ${FORMATTED_SALT}"); + + byte[] rawSalt = new byte[16] + def params = [] + + // Act + cipherProvider.parseSalt(FORMATTED_SALT, rawSalt, params) + + // Assert + assert rawSalt == EXPECTED_RAW_SALT + assert params[0] == EXPECTED_N + assert params[1] == EXPECTED_R + assert params[2] == EXPECTED_P + } + + @Ignore("This test can be run on a specific machine to evaluate if the default parameters are sufficient") + @Test + public void testDefaultConstructorShouldProvideStrongParameters() { + // Arrange + ScryptCipherProvider testCipherProvider = new ScryptCipherProvider() + + /** See this Stack Overflow answer for a good visualization of the interplay between N, r, p http://stackoverflow.com/a/30308723 */ + + // Act + int n = testCipherProvider.getN() + int r = testCipherProvider.getR() + int p = testCipherProvider.getP() + logger.info("Default parameters N=${n}, r=${r}, p=${p}") + + // Calculate the parameters to reach 500 ms + def (int minimumN, int minimumR, int minimumP) = calculateMinimumParameters(r, p) + logger.info("Determined minimum safe parameters to be N=${minimumN}, r=${minimumR}, p=${minimumP}") + + // Assert + assertTrue("The default parameters for ScryptCipherProvider are too weak. Please update the default values to a stronger level.", n >= minimumN) + } + + /** + * Returns the parameters 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 + * + * @param r the block size in bytes (defaults to 8) + * @param p the parallelization factor (defaults to 1) + * @param maxHeapSize the maximum heap size to use in bytes (defaults to 1 GB) + * + * @return the minimum scrypt parameters as [N, r, p] + */ + private static List calculateMinimumParameters(int r = 8, int p = 1, int maxHeapSize = 1024 * 1024 * 1024) { + // High start-up cost, so run multiple times for better benchmarking + final int RUNS = 10 + + // Benchmark using N=2^4 + int n = 2**4 + int dkLen = 128 + + assert Scrypt.calculateExpectedMemory(n, r, p) <= maxHeapSize + + byte[] salt = new byte[Scrypt.defaultSaltLength] + new SecureRandom().nextBytes(salt) + + // Run once to prime the system + double duration = time { + Scrypt.scrypt(MICROBENCHMARK, salt, n, r, p, dkLen) + } + logger.info("First run of N=${n}, r=${r}, p=${p} took ${duration} ms (ignored)") + + def durations = [] + + RUNS.times { int i -> + duration = time { + Scrypt.scrypt(MICROBENCHMARK, salt, n, r, p, dkLen) + } + logger.info("N=${n}, r=${r}, p=${p} took ${duration} ms") + durations << duration + } + + duration = durations.sum() / durations.size() + logger.info("N=${n}, r=${r}, p=${p} averaged ${duration} ms") + + // Doubling N would double the run time + // Keep increasing N until the estimated duration is over 500 ms + while (duration < 500) { + n *= 2 + duration *= 2 + } + + logger.info("Returning N=${n}, r=${r}, p=${p} for ${duration} ms") + + return [n, r, p] + } + + 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/7d242076/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/scrypt/ScryptGroovyTest.groovy ---------------------------------------------------------------------- diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/scrypt/ScryptGroovyTest.groovy b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/scrypt/ScryptGroovyTest.groovy new file mode 100644 index 0000000..da34c49 --- /dev/null +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/scrypt/ScryptGroovyTest.groovy @@ -0,0 +1,399 @@ +/* + * 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.security.util.crypto.scrypt + +import org.apache.commons.codec.binary.Hex +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.slf4j.LoggerFactory + +import java.security.SecureRandom +import java.security.Security + +import static groovy.test.GroovyAssert.shouldFail + +@RunWith(JUnit4.class) +public class ScryptGroovyTest { + private static final Logger logger = LoggerFactory.getLogger(ScryptGroovyTest.class) + + private static final String PASSWORD = "shortPassword" + private static final String SALT_HEX = "0123456789ABCDEFFEDCBA9876543210" + private static final byte[] SALT_BYTES = Hex.decodeHex(SALT_HEX as char[]) + + // Small values to test for correctness, not timing + private static final int N = 2**4 + private static final int R = 1 + private static final int P = 1 + private static final int DK_LEN = 128 + private static final long TWO_GIGABYTES = 2048L * 1024 * 1024 + + @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 testDeriveScryptKeyShouldBeInternallyConsistent() throws Exception { + // Arrange + def allKeys = [] + final int RUNS = 10 + + logger.info("Running with '${PASSWORD}', '${SALT_HEX}', $N, $R, $P, $DK_LEN") + + // Act + RUNS.times { + byte[] keyBytes = Scrypt.deriveScryptKey(PASSWORD.bytes, SALT_BYTES, N, R, P, DK_LEN) + logger.info("Derived key: ${Hex.encodeHexString(keyBytes)}") + allKeys << keyBytes + } + + // Assert + assert allKeys.size() == RUNS + assert allKeys.every { it == allKeys.first() } + } + + /** + * This test ensures that the local implementation of Scrypt is compatible with the reference implementation from the Colin Percival paper. + */ + @Test + public void testDeriveScryptKeyShouldMatchTestVectors() { + // Arrange + + // These values are taken from Colin Percival's scrypt paper: https://www.tarsnap.com/scrypt/scrypt.pdf + final byte[] HASH_2 = Hex.decodeHex("fdbabe1c9d3472007856e7190d01e9fe" + + "7c6ad7cbc8237830e77376634b373162" + + "2eaf30d92e22a3886ff109279d9830da" + + "c727afb94a83ee6d8360cbdfa2cc0640" as char[]) + + final byte[] HASH_3 = Hex.decodeHex("7023bdcb3afd7348461c06cd81fd38eb" + + "fda8fbba904f8e3ea9b543f6545da1f2" + + "d5432955613f0fcf62d49705242a9af9" + + "e61e85dc0d651e40dfcf017b45575887" as char[]) + + final def TEST_VECTORS = [ + // Empty password is not supported by JCE + [password: "password", + salt : "NaCl", + n : 1024, + r : 8, + p : 16, + dkLen : 64 * 8, + hash : HASH_2], + [password: "pleaseletmein", + salt : "SodiumChloride", + n : 16384, + r : 8, + p : 1, + dkLen : 64 * 8, + hash : HASH_3], + ] + + // Act + TEST_VECTORS.each { Map params -> + logger.info("Running with '${params.password}', '${params.salt}', ${params.n}, ${params.r}, ${params.p}, ${params.dkLen}") + long memoryInBytes = Scrypt.calculateExpectedMemory(params.n, params.r, params.p) + logger.info("Expected memory usage: (128 * r * N + 128 * r * p) ${memoryInBytes} bytes") + logger.info(" Expected ${Hex.encodeHexString(params.hash)}") + + byte[] calculatedHash = Scrypt.deriveScryptKey(params.password.bytes, params.salt.bytes, params.n, params.r, params.p, params.dkLen) + logger.info("Generated ${Hex.encodeHexString(calculatedHash)}") + + // Assert + assert calculatedHash == params.hash + } + } + + /** + * This test ensures that the local implementation of Scrypt is compatible with the reference implementation from the Colin Percival paper. The test vector requires ~1GB {@code byte[]} + * and therefore the Java heap must be at least 1GB. Because {@link nifi/pom.xml} has a {@code surefire} rule which appends {@code -Xmx1G} + * to the Java options, this overrides any IDE options. To ensure the heap is properly set, using the {@code groovyUnitTest} profile will re-append {@code -Xmx3072m} to the Java options. + */ + @Test + public void testDeriveScryptKeyShouldMatchExpensiveTestVector() { + // Arrange + long totalMemory = Runtime.getRuntime().totalMemory() + logger.info("Required memory: ${TWO_GIGABYTES} bytes") + logger.info("Max heap memory: ${totalMemory} bytes") + Assume.assumeTrue("Test is being skipped due to JVM heap size. Please run with -Xmx3072m to set sufficient heap size", + totalMemory >= TWO_GIGABYTES) + + // These values are taken from Colin Percival's scrypt paper: https://www.tarsnap.com/scrypt/scrypt.pdf + final byte[] HASH = Hex.decodeHex("2101cb9b6a511aaeaddbbe09cf70f881" + + "ec568d574a2ffd4dabe5ee9820adaa47" + + "8e56fd8f4ba5d09ffa1c6d927c40f4c3" + + "37304049e8a952fbcbf45c6fa77a41a4" as char[]) + + // This test vector requires 2GB heap space and approximately 10 seconds on a consumer machine + String password = "pleaseletmein" + String salt = "SodiumChloride" + int n = 1048576 + int r = 8 + int p = 1 + int dkLen = 64 * 8 + + // Act + logger.info("Running with '${password}', '${salt}', ${n}, ${r}, ${p}, ${dkLen}") + long memoryInBytes = Scrypt.calculateExpectedMemory(n, r, p) + logger.info("Expected memory usage: (128 * r * N + 128 * r * p) ${memoryInBytes} bytes") + logger.info(" Expected ${Hex.encodeHexString(HASH)}") + + byte[] calculatedHash = Scrypt.deriveScryptKey(password.bytes, salt.bytes, n, r, p, dkLen) + logger.info("Generated ${Hex.encodeHexString(calculatedHash)}") + + // Assert + assert calculatedHash == HASH + } + + @Ignore("This test was just to exercise the heap and debug OOME issues") + @Test + void testShouldCauseOutOfMemoryError() { + SecureRandom secureRandom = new SecureRandom() +// int i = 29 + (10..31).each { int i -> + int length = 2**i + byte[] bytes = new byte[length] + secureRandom.nextBytes(bytes) + logger.info("Successfully ran with byte[] of length ${length}") + logger.info("${Hex.encodeHexString(bytes[0..<16] as byte[])}...") + } + } + + @Test + public void testDeriveScryptKeyShouldSupportExternalCompatibility() { + // Arrange + + // These values can be generated by running `$ ./openssl_scrypt.rb` in the terminal + final String EXPECTED_KEY_HEX = "a8efbc0a709d3f89b6bb35b05fc8edf5" + String password = "thisIsABadPassword" + String saltHex = "f5b8056ea6e66edb8d013ac432aba24a" + int n = 1024 + int r = 8 + int p = 36 + int dkLen = 16 * 8 + + // Act + logger.info("Running with '${password}', ${saltHex}, ${n}, ${r}, ${p}, ${dkLen}") + long memoryInBytes = Scrypt.calculateExpectedMemory(n, r, p) + logger.info("Expected memory usage: (128 * r * N + 128 * r * p) ${memoryInBytes} bytes") + logger.info(" Expected ${EXPECTED_KEY_HEX}") + + byte[] calculatedHash = Scrypt.deriveScryptKey(password.bytes, Hex.decodeHex(saltHex as char[]), n, r, p, dkLen) + logger.info("Generated ${Hex.encodeHexString(calculatedHash)}") + + // Assert + assert calculatedHash == Hex.decodeHex(EXPECTED_KEY_HEX as char[]) + } + + @Test + public void testScryptShouldBeInternallyConsistent() throws Exception { + // Arrange + def allHashes = [] + final int RUNS = 10 + + logger.info("Running with '${PASSWORD}', '${SALT_HEX}', $N, $R, $P") + + // Act + RUNS.times { + String hash = Scrypt.scrypt(PASSWORD, SALT_BYTES, N, R, P, DK_LEN) + logger.info("Hash: ${hash}") + allHashes << hash + } + + // Assert + assert allHashes.size() == RUNS + assert allHashes.every { it == allHashes.first() } + } + + @Test + public void testScryptShouldGenerateValidSaltIfMissing() { + // Arrange + + // The generated salt should be byte[16], encoded as 22 Base64 chars + final def EXPECTED_SALT_PATTERN = /\$.+\$[0-9a-zA-Z\/\+]{22}\$.+/ + + // Act + String calculatedHash = Scrypt.scrypt(PASSWORD, N, R, P, DK_LEN) + logger.info("Generated ${calculatedHash}") + + // Assert + assert calculatedHash =~ EXPECTED_SALT_PATTERN + } + + @Test + public void testScryptShouldNotAcceptInvalidN() throws Exception { + // Arrange + + final int MAX_N = Integer.MAX_VALUE / 128 / R - 1 + + // N must be a power of 2 > 1 and < Integer.MAX_VALUE / 128 / r + final def INVALID_NS = [-2, 0, 1, 3, 4096 - 1, MAX_N + 1] + + // Act + INVALID_NS.each { int invalidN -> + logger.info("Using N: ${invalidN}") + + def msg = shouldFail(IllegalArgumentException) { + Scrypt.deriveScryptKey(PASSWORD.bytes, SALT_BYTES, invalidN, R, P, DK_LEN) + } + + // Assert + assert msg =~ "N must be a power of 2 greater than 1|Parameter N is too large" + } + } + + @Test + public void testScryptShouldAcceptValidR() throws Exception { + // Arrange + + // Use a large p value to allow r to exceed MAX_R without normal N exceeding MAX_N + int largeP = 2**10 + final int MAX_R = Math.ceil(Integer.MAX_VALUE / 128 / largeP) - 1 + + // r must be in (0..Integer.MAX_VALUE / 128 / p) + final def INVALID_RS = [0, MAX_R + 1] + + // Act + INVALID_RS.each { int invalidR -> + logger.info("Using r: ${invalidR}") + + def msg = shouldFail(IllegalArgumentException) { + byte[] hash = Scrypt.deriveScryptKey(PASSWORD.bytes, SALT_BYTES, N, invalidR, largeP, DK_LEN) + logger.info("Generated hash: ${Hex.encodeHexString(hash)}") + } + + // Assert + assert msg =~ "Parameter r must be 1 or greater|Parameter r is too large" + } + } + + @Test + public void testScryptShouldNotAcceptInvalidP() throws Exception { + // Arrange + final int MAX_P = Math.ceil(Integer.MAX_VALUE / 128) - 1 + + // p must be in (0..Integer.MAX_VALUE / 128) + final def INVALID_PS = [0, MAX_P + 1] + + // Act + INVALID_PS.each { int invalidP -> + logger.info("Using p: ${invalidP}") + + def msg = shouldFail(IllegalArgumentException) { + byte[] hash = Scrypt.deriveScryptKey(PASSWORD.bytes, SALT_BYTES, N, R, invalidP, DK_LEN) + logger.info("Generated hash: ${Hex.encodeHexString(hash)}") + } + + // Assert + assert msg =~ "Parameter p must be 1 or greater|Parameter p is too large" + } + } + + @Test + public void testCheckShouldValidateCorrectPassword() throws Exception { + // Arrange + final String PASSWORD = "thisIsABadPassword" + final String EXPECTED_HASH = Scrypt.scrypt(PASSWORD, N, R, P, DK_LEN) + logger.info("Password: ${PASSWORD} -> Hash: ${EXPECTED_HASH}") + + // Act + boolean matches = Scrypt.check(PASSWORD, EXPECTED_HASH) + logger.info("Check matches: ${matches}") + + // Assert + assert matches + } + + @Test + public void testCheckShouldNotValidateIncorrectPassword() throws Exception { + // Arrange + final String PASSWORD = "thisIsABadPassword" + final String EXPECTED_HASH = Scrypt.scrypt(PASSWORD, N, R, P, DK_LEN) + logger.info("Password: ${PASSWORD} -> Hash: ${EXPECTED_HASH}") + + // Act + boolean matches = Scrypt.check(PASSWORD.reverse(), EXPECTED_HASH) + logger.info("Check matches: ${matches}") + + // Assert + assert !matches + } + + @Test + public void testCheckShouldNotAcceptInvalidPassword() throws Exception { + // Arrange + final String HASH = '$s0$a0801$abcdefghijklmnopqrstuv$abcdefghijklmnopqrstuv' + + // Even though the spec allows for empty passwords, the JCE does not, so extend enforcement of that to the user boundary + final def INVALID_PASSWORDS = ['', null] + + // Act + INVALID_PASSWORDS.each { String invalidPassword -> + logger.info("Using password: ${invalidPassword}") + + def msg = shouldFail(IllegalArgumentException) { + boolean matches = Scrypt.check(invalidPassword, HASH) + } + logger.expected(msg) + + // Assert + assert msg =~ "Password cannot be empty" + } + } + + @Test + public void testCheckShouldNotAcceptInvalidHash() throws Exception { + // Arrange + final String PASSWORD = "thisIsABadPassword" + + // Even though the spec allows for empty salts, the JCE does not, so extend enforcement of that to the user boundary + final def INVALID_HASHES = ['', null, '$s0$a0801$', '$s0$a0801$abcdefghijklmnopqrstuv$'] + + // Act + INVALID_HASHES.each { String invalidHash -> + logger.info("Using hash: ${invalidHash}") + + def msg = shouldFail(IllegalArgumentException) { + boolean matches = Scrypt.check(PASSWORD, invalidHash) + } + logger.expected(msg) + + // Assert + assert msg =~ "Hash cannot be empty|Hash is not properly formatted" + } + } +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/nifi/blob/7d242076/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/java/org/apache/nifi/processors/standard/TestEncryptContent.java ---------------------------------------------------------------------- diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/java/org/apache/nifi/processors/standard/TestEncryptContent.java b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/java/org/apache/nifi/processors/standard/TestEncryptContent.java index 3166bd0..063652b 100644 --- a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/java/org/apache/nifi/processors/standard/TestEncryptContent.java +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/java/org/apache/nifi/processors/standard/TestEncryptContent.java @@ -16,12 +16,17 @@ */ package org.apache.nifi.processors.standard; +import java.io.File; +import java.io.IOException; +import java.nio.file.Paths; +import java.security.Security; +import java.util.Collection; import org.apache.commons.codec.binary.Hex; import org.apache.nifi.components.ValidationResult; -import org.apache.nifi.processors.standard.util.crypto.CipherUtility; -import org.apache.nifi.processors.standard.util.crypto.PasswordBasedEncryptor; import org.apache.nifi.security.util.EncryptionMethod; import org.apache.nifi.security.util.KeyDerivationFunction; +import org.apache.nifi.security.util.crypto.CipherUtility; +import org.apache.nifi.security.util.crypto.PasswordBasedEncryptor; import org.apache.nifi.util.MockFlowFile; import org.apache.nifi.util.MockProcessContext; import org.apache.nifi.util.TestRunner; @@ -34,12 +39,6 @@ import org.junit.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.File; -import java.io.IOException; -import java.nio.file.Paths; -import java.security.Security; -import java.util.Collection; - public class TestEncryptContent { private static final Logger logger = LoggerFactory.getLogger(TestEncryptContent.class); http://git-wip-us.apache.org/repos/asf/nifi/blob/7d242076/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/java/org/apache/nifi/processors/standard/util/crypto/OpenPGPKeyBasedEncryptorTest.java ---------------------------------------------------------------------- diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/java/org/apache/nifi/processors/standard/util/crypto/OpenPGPKeyBasedEncryptorTest.java b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/java/org/apache/nifi/processors/standard/util/crypto/OpenPGPKeyBasedEncryptorTest.java deleted file mode 100644 index b4cd2e3..0000000 --- a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/java/org/apache/nifi/processors/standard/util/crypto/OpenPGPKeyBasedEncryptorTest.java +++ /dev/null @@ -1,132 +0,0 @@ -/* - * 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.Assert; -import org.junit.Before; -import org.junit.BeforeClass; -import org.junit.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.InputStream; -import java.io.OutputStream; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.security.Security; - -public class OpenPGPKeyBasedEncryptorTest { - private static final Logger logger = LoggerFactory.getLogger(OpenPGPKeyBasedEncryptorTest.class); - - private final File plainFile = new File("src/test/resources/TestEncryptContent/text.txt"); - private final File unsignedFile = new File("src/test/resources/TestEncryptContent/text.txt.unsigned.gpg"); - private final File encryptedFile = new File("src/test/resources/TestEncryptContent/text.txt.gpg"); - - private static final String SECRET_KEYRING_PATH = "src/test/resources/TestEncryptContent/secring.gpg"; - private static final String PUBLIC_KEYRING_PATH = "src/test/resources/TestEncryptContent/pubring.gpg"; - private static final String USER_ID = "NiFi PGP Test Key (Short test key for NiFi PGP unit tests) "; - - private static final String PASSWORD = "thisIsABadPassword"; - - @BeforeClass - public static void setUpOnce() throws Exception { - Security.addProvider(new BouncyCastleProvider()); - } - - @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(); - - // No file, just streams - String filename = "tempFile.txt"; - - // Encryptor does not require password - OpenPGPKeyBasedEncryptor encryptor = new OpenPGPKeyBasedEncryptor( - EncryptionMethod.PGP.getAlgorithm(), EncryptionMethod.PGP.getProvider(), PUBLIC_KEYRING_PATH, USER_ID, new char[0], filename); - StreamCallback encryptionCallback = encryptor.getEncryptionCallback(); - - OpenPGPKeyBasedEncryptor decryptor = new OpenPGPKeyBasedEncryptor( - EncryptionMethod.PGP.getAlgorithm(), EncryptionMethod.PGP.getProvider(), SECRET_KEYRING_PATH, USER_ID, PASSWORD.toCharArray(), filename); - StreamCallback decryptionCallback = decryptor.getDecryptionCallback(); - - // Act - 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: {}", recovered); - assert PLAINTEXT.equals(recovered); - } - - @Test - public void testShouldDecryptExternalFile() throws Exception { - // Arrange - byte[] plainBytes = Files.readAllBytes(Paths.get(plainFile.getPath())); - final String PLAINTEXT = new String(plainBytes, "UTF-8"); - - InputStream cipherStream = new FileInputStream(unsignedFile); - OutputStream recoveredStream = new ByteArrayOutputStream(); - - // No file, just streams - String filename = unsignedFile.getName(); - - OpenPGPKeyBasedEncryptor encryptor = new OpenPGPKeyBasedEncryptor( - EncryptionMethod.PGP.getAlgorithm(), EncryptionMethod.PGP.getProvider(), SECRET_KEYRING_PATH, USER_ID, PASSWORD.toCharArray(), filename); - - StreamCallback decryptionCallback = encryptor.getDecryptionCallback(); - - // Act - decryptionCallback.process(cipherStream, recoveredStream); - - // Assert - byte[] recoveredBytes = ((ByteArrayOutputStream) recoveredStream).toByteArray(); - String recovered = new String(recoveredBytes, "UTF-8"); - logger.info("Recovered: {}", recovered); - Assert.assertEquals("Recovered text", PLAINTEXT, recovered); - } -} \ No newline at end of file