You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@nifi.apache.org by al...@apache.org on 2020/07/24 19:32:22 UTC
[nifi] branch main updated: NIFI-7122 Introduced improvements to
EncryptContent processor. Added unit tests. Refactored shared logic from
various algorithm-specific secure hasher implementations to
AbstractSecureHasher. Introduced secure hasher implementations for various
KDFs. Added custom validation to EncryptContent processor. Implemented
logic for EncryptContent to write operational metadata to flowfile
attributes. Added encryption metadata attribute annotations to
EncryptContent. Added Argon2 KDF documentation a [...]
This is an automated email from the ASF dual-hosted git repository.
alopresto pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new eeeda84 NIFI-7122 Introduced improvements to EncryptContent processor. Added unit tests. Refactored shared logic from various algorithm-specific secure hasher implementations to AbstractSecureHasher. Introduced secure hasher implementations for various KDFs. Added custom validation to EncryptContent processor. Implemented logic for EncryptContent to write operational metadata to flowfile attributes. Added encryption metadata attribute annotations to EncryptContent. Added Argon2 [...]
eeeda84 is described below
commit eeeda844747207102711cc2caad4c9a93982371a
Author: Andy LoPresto <al...@apache.org>
AuthorDate: Thu Mar 12 16:42:22 2020 -0700
NIFI-7122 Introduced improvements to EncryptContent processor.
Added unit tests.
Refactored shared logic from various algorithm-specific secure hasher implementations to AbstractSecureHasher.
Introduced secure hasher implementations for various KDFs.
Added custom validation to EncryptContent processor.
Implemented logic for EncryptContent to write operational metadata to flowfile attributes.
Added encryption metadata attribute annotations to EncryptContent.
Added Argon2 KDF documentation and Bcrypt key derivation change notes to Admin Guide.
Updated unit tests to calculate default/recommended cost parameters for Argon2.
This closes #4421.
Co-authored-by: mtien <mt...@gmail.com>
---
.../java/org/apache/nifi/flowfile/FlowFile.java | 1 +
.../nifi/security/util/EncryptionMethod.java | 1 +
.../nifi/security/util/KeyDerivationFunction.java | 21 +-
.../util/crypto/AESKeyedCipherProvider.java | 4 +-
.../security/util/crypto/AbstractSecureHasher.java | 272 +++++++++++
.../security/util/crypto/Argon2CipherProvider.java | 295 ++++++++++++
.../security/util/crypto/Argon2SecureHasher.java | 159 +++----
.../security/util/crypto/BcryptCipherProvider.java | 197 +++++---
.../security/util/crypto/BcryptSecureHasher.java | 210 ++++++++
.../util/crypto/CipherProviderFactory.java | 5 +-
.../nifi/security/util/crypto/CipherUtility.java | 121 ++++-
.../security/util/crypto/PBKDF2CipherProvider.java | 21 +-
.../security/util/crypto/PBKDF2SecureHasher.java | 287 +++++++++++
.../util/crypto/RandomIVPBECipherProvider.java | 4 +-
.../security/util/crypto/ScryptCipherProvider.java | 128 +++--
.../security/util/crypto/ScryptSecureHasher.java | 265 +++++++++++
.../nifi/security/util/crypto/SecureHasher.java | 30 ++
.../nifi/security/util/crypto/scrypt/Scrypt.java | 21 +
...roovy => Argon2CipherProviderGroovyTest.groovy} | 421 ++++++++---------
.../util/crypto/Argon2SecureHasherTest.groovy | 144 +++++-
.../crypto/BcryptCipherProviderGroovyTest.groovy | 199 +++++++-
.../util/crypto/BcryptSecureHasherTest.groovy | 415 ++++++++++++++++
.../crypto/CipherProviderFactoryGroovyTest.groovy | 13 +-
.../util/crypto/CipherUtilityGroovyTest.groovy | 63 ++-
.../NiFiLegacyCipherProviderGroovyTest.groovy | 2 +-
.../crypto/PBKDF2CipherProviderGroovyTest.groovy | 40 +-
.../util/crypto/PBKDF2SecureHasherTest.groovy | 417 ++++++++++++++++
.../crypto/ScryptCipherProviderGroovyTest.groovy | 46 +-
.../util/crypto/ScryptSecureHasherTest.groovy | 485 +++++++++++++++++++
.../{openssl_bcrypt.rb => openssl_argon2.rb} | 49 +-
.../src/test/resources/openssl_argon2_recovery.rb | 96 ++++
.../src/test/resources/openssl_bcrypt.rb | 8 +-
.../src/test/resources/openssl_bcrypt_recovery.rb | 94 ++++
...penssl_bcrypt.rb => openssl_pbkdf2_recovery.rb} | 46 +-
.../src/test/resources/openssl_scrypt_recovery.rb | 96 ++++
.../src/main/asciidoc/administration-guide.adoc | 101 ++--
nifi-docs/src/main/asciidoc/images/argon2-salt.png | Bin 0 -> 468557 bytes
.../java/org/apache/nifi/util/MockFlowFile.java | 44 +-
.../crypto/EncryptedFileSystemRepository.java | 7 +-
.../nifi/processors/standard/EncryptContent.java | 259 ++++++----
.../security/util/crypto/AbstractEncryptor.java | 103 ++++
.../nifi/security/util/crypto/KeyedEncryptor.java | 54 ++-
.../util/crypto/OpenPGPKeyBasedEncryptor.java | 6 +
.../util/crypto/OpenPGPPasswordBasedEncryptor.java | 6 +
.../util/crypto/PasswordBasedEncryptor.java | 162 ++++++-
.../standard/TestEncryptContentGroovy.groovy | 526 ++++++++++++++++++++-
.../util/crypto/KeyedEncryptorGroovyTest.groovy | 84 +++-
.../crypto/PasswordBasedEncryptorGroovyTest.groovy | 290 +++++++++++-
.../processors/standard/TestEncryptContent.java | 53 ++-
49 files changed, 5633 insertions(+), 738 deletions(-)
diff --git a/nifi-api/src/main/java/org/apache/nifi/flowfile/FlowFile.java b/nifi-api/src/main/java/org/apache/nifi/flowfile/FlowFile.java
index c9eb49a..f396017 100644
--- a/nifi-api/src/main/java/org/apache/nifi/flowfile/FlowFile.java
+++ b/nifi-api/src/main/java/org/apache/nifi/flowfile/FlowFile.java
@@ -108,6 +108,7 @@ public interface FlowFile extends Comparable<FlowFile> {
*/
Map<String, String> getAttributes();
+ // All classes defined in an interface are public static, but keeping the definition to be explicit
public static class KeyValidator {
public static String validateKey(final String key) {
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/EncryptionMethod.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/EncryptionMethod.java
index 028c915..62d72a2 100644
--- a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/EncryptionMethod.java
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/EncryptionMethod.java
@@ -26,6 +26,7 @@ import org.apache.commons.lang3.builder.ToStringStyle;
*/
public enum EncryptionMethod {
+ // TODO: Reorder so keyed ciphers are first moving forward (default PD value, sorting, etc.)
MD5_128AES("PBEWITHMD5AND128BITAES-CBC-OPENSSL", "BC", false, false),
MD5_192AES("PBEWITHMD5AND192BITAES-CBC-OPENSSL", "BC", true, false),
MD5_256AES("PBEWITHMD5AND256BITAES-CBC-OPENSSL", "BC", true, false),
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/KeyDerivationFunction.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/KeyDerivationFunction.java
index 48ea2d4..5c79df7 100644
--- a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/KeyDerivationFunction.java
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/KeyDerivationFunction.java
@@ -24,23 +24,24 @@ import org.apache.commons.lang3.builder.ToStringStyle;
*/
public enum KeyDerivationFunction {
+ NONE("None", "The cipher is given a raw key conforming to the algorithm specifications"),
NIFI_LEGACY("NiFi Legacy KDF", "MD5 @ 1000 iterations"),
OPENSSL_EVP_BYTES_TO_KEY("OpenSSL EVP_BytesToKey", "Single iteration MD5 compatible with PKCS#5 v1.5"),
BCRYPT("Bcrypt", "Bcrypt with configurable work factor. See Admin Guide"),
SCRYPT("Scrypt", "Scrypt with configurable cost parameters. See Admin Guide"),
PBKDF2("PBKDF2", "PBKDF2 with configurable hash function and iteration count. See Admin Guide"),
- NONE("None", "The cipher is given a raw key conforming to the algorithm specifications");
+ ARGON2("Argon2", "Argon2 with configurable cost parameters. See Admin Guide.");
- private final String name;
+ private final String kdfName;
private final String description;
- KeyDerivationFunction(String name, String description) {
- this.name = name;
+ KeyDerivationFunction(String kdfName, String description) {
+ this.kdfName = kdfName;
this.description = description;
}
- public String getName() {
- return name;
+ public String getKdfName() {
+ return kdfName;
}
public String getDescription() {
@@ -48,14 +49,18 @@ public enum KeyDerivationFunction {
}
public boolean isStrongKDF() {
- return (name.equals(BCRYPT.name) || name.equals(SCRYPT.name) || name.equals(PBKDF2.name));
+ return (kdfName.equals(BCRYPT.kdfName) || kdfName.equals(SCRYPT.kdfName) || kdfName.equals(PBKDF2.kdfName) || kdfName.equals(ARGON2.kdfName));
+ }
+
+ public boolean hasFormattedSalt() {
+ return kdfName.equals(BCRYPT.kdfName) || kdfName.equals(SCRYPT.kdfName) || kdfName.equals(ARGON2.kdfName);
}
@Override
public String toString() {
final ToStringBuilder builder = new ToStringBuilder(this);
ToStringBuilder.setDefaultStyle(ToStringStyle.SHORT_PREFIX_STYLE);
- builder.append("KDF Name", name);
+ builder.append("KDF Name", kdfName);
builder.append("Description", description);
return builder.toString();
}
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/AESKeyedCipherProvider.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/AESKeyedCipherProvider.java
index 617a3e5..794ac97 100644
--- a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/AESKeyedCipherProvider.java
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/AESKeyedCipherProvider.java
@@ -16,13 +16,11 @@
*/
package org.apache.nifi.security.util.crypto;
-import java.io.UnsupportedEncodingException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.SecureRandom;
-import java.security.spec.InvalidKeySpecException;
import java.util.Arrays;
import java.util.List;
import javax.crypto.Cipher;
@@ -83,7 +81,7 @@ public class AESKeyedCipherProvider extends KeyedCipherProvider {
protected Cipher getInitializedCipher(EncryptionMethod encryptionMethod, SecretKey key, byte[] iv,
boolean encryptMode) throws NoSuchAlgorithmException, NoSuchProviderException,
- InvalidKeySpecException, NoSuchPaddingException, InvalidKeyException, InvalidAlgorithmParameterException, UnsupportedEncodingException {
+ NoSuchPaddingException, InvalidKeyException, InvalidAlgorithmParameterException {
if (encryptionMethod == null) {
throw new IllegalArgumentException("The encryption method must be specified");
}
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/AbstractSecureHasher.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/AbstractSecureHasher.java
new file mode 100644
index 0000000..3b0c6aa
--- /dev/null
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/AbstractSecureHasher.java
@@ -0,0 +1,272 @@
+/*
+ * 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.bouncycastle.util.encoders.Hex;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+
+public abstract class AbstractSecureHasher implements SecureHasher {
+ private static final Logger logger = LoggerFactory.getLogger(AbstractSecureHasher.class);
+
+ protected int saltLength;
+
+ private boolean usingStaticSalt;
+
+ // A 16 byte salt (nonce) is recommended for password hashing
+ private static final byte[] STATIC_SALT = "NiFi Static Salt".getBytes(StandardCharsets.UTF_8);
+
+ // Upper boundary for several cost parameters
+ static final Integer UPPER_BOUNDARY = Double.valueOf(Math.pow(2, 32)).intValue() - 1;
+
+ /**
+ * Verifies the salt length is valid for this algorithm and if a static salt should be used.
+ *
+ * @param saltLength the salt length in bytes
+ */
+ protected void initializeSalt(Integer saltLength) {
+ if (saltLength > 0) {
+ if (!isSaltLengthValid(saltLength)) {
+ logger.error("The salt length {} is outside the boundary of {} to {}.", saltLength, getMinSaltLength(), getMaxSaltLength());
+ throw new IllegalArgumentException("Invalid salt length exceeds the saltLength boundary.");
+ }
+ this.usingStaticSalt = false;
+ } else {
+ this.usingStaticSalt = true;
+ logger.debug("Configured to use static salt");
+ }
+ }
+
+ /**
+ * Returns whether the provided salt length (saltLength) is within boundaries. The lower bound >= (usually) 8 and the
+ * upper bound <= (usually) {@link Integer#MAX_VALUE}. This method is not {@code static} because it depends on the
+ * instantiation of the algorithm-specific concrete class.
+ *
+ * @param saltLength the salt length in bytes
+ * @return true if saltLength is within boundaries
+ */
+ public boolean isSaltLengthValid(Integer saltLength) {
+ final int SALT_LENGTH = getDefaultSaltLength();
+ if (saltLength == 0) {
+ logger.debug("The provided salt length 0 indicates a static salt of {} bytes", SALT_LENGTH);
+ return true;
+ }
+ if (saltLength < SALT_LENGTH) {
+ logger.warn("The provided dynamic salt length {} is below the recommended minimum {}", saltLength, SALT_LENGTH);
+ }
+ return saltLength >= getMinSaltLength() && saltLength <= getMaxSaltLength();
+ }
+
+ /**
+ * Returns the algorithm-specific default salt length in bytes.
+ *
+ * @return the default salt length
+ */
+ abstract int getDefaultSaltLength();
+
+ /**
+ * Returns the algorithm-specific minimum salt length in bytes.
+ *
+ * @return the min salt length
+ */
+ abstract int getMinSaltLength();
+
+ /**
+ * Returns the algorithm-specific maximum salt length in bytes.
+ *
+ * @return the max salt length
+ */
+ abstract int getMaxSaltLength();
+
+ /**
+ * Returns {@code true} if this instance is configured to use a static salt.
+ *
+ * @return true if all hashes will be generated using a static salt
+ */
+ public boolean isUsingStaticSalt() {
+ return usingStaticSalt;
+ }
+
+ /**
+ * Returns a salt to use. If using a static salt (see {@link #isUsingStaticSalt()}),
+ * this return value will be identical across every invocation. If using a dynamic salt,
+ * it will be {@link #saltLength} bytes of a securely-generated random value.
+ *
+ * @return the salt value
+ */
+ byte[] getSalt() {
+ if (isUsingStaticSalt()) {
+ return STATIC_SALT;
+ } else {
+ SecureRandom sr = new SecureRandom();
+ byte[] salt = new byte[saltLength];
+ sr.nextBytes(salt);
+ return salt;
+ }
+ }
+
+ /**
+ * Returns the algorithm-specific name for logging and messages.
+ *
+ * @return the algorithm name
+ */
+ abstract String getAlgorithmName();
+
+ /**
+ * Returns {@code true} if the algorithm can accept empty (non-{@code null}) inputs.
+ *
+ * @return the true if {@code ""} is allowable input
+ */
+ abstract boolean acceptsEmptyInput();
+
+ /**
+ * Returns a String representation of the hash in hex-encoded format.
+ *
+ * @param input the non-empty input
+ * @return the hex-encoded hash
+ */
+ @Override
+ public String hashHex(String input) {
+ try {
+ input = validateInput(input);
+ } catch (IllegalArgumentException e) {
+ return "";
+ }
+ return Hex.toHexString(hash(input.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ /**
+ * Returns a String representation of the hash in hex-encoded format.
+ *
+ * @param input the non-empty input
+ * @param salt the provided salt
+ *
+ * @return the hex-encoded hash
+ */
+ @Override
+ public String hashHex(String input, String salt) {
+ try {
+ input = validateInput(input);
+ } catch (IllegalArgumentException e) {
+ return "";
+ }
+
+ return Hex.toHexString(hash(input.getBytes(StandardCharsets.UTF_8), salt.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ /**
+ * Returns a String representation of the hash in Base 64-encoded format.
+ *
+ * @param input the non-empty input
+ * @return the Base 64-encoded hash
+ */
+ @Override
+ public String hashBase64(String input) {
+ try {
+ input = validateInput(input);
+ } catch (IllegalArgumentException e) {
+ return "";
+ }
+
+ return CipherUtility.encodeBase64NoPadding(hash(input.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ /**
+ * Returns a String representation of the hash in Base 64-encoded format.
+ *
+ * @param input the non-empty input
+ * @param salt the provided salt
+ *
+ * @return the Base 64-encoded hash
+ */
+ @Override
+ public String hashBase64(String input, String salt) {
+ try {
+ input = validateInput(input);
+ } catch (IllegalArgumentException e) {
+ return "";
+ }
+
+ return CipherUtility.encodeBase64NoPadding(hash(input.getBytes(StandardCharsets.UTF_8), salt.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ /**
+ * Returns a byte[] representation of {@code SecureHasher.hash(input)}.
+ *
+ * @param input the input
+ * @return the hash
+ */
+ public byte[] hashRaw(byte[] input) {
+ return hash(input);
+ }
+
+ /**
+ * Returns a byte[] representation of {@code SecureHasher.hash(input)}.
+ *
+ * @param input the input
+ * @param salt the provided salt
+ *
+ * @return the hash
+ */
+ @Override
+ public byte[] hashRaw(byte[] input, byte[] salt) {
+ return hash(input, salt);
+ }
+
+ /**
+ * Returns the valid {@code input} String (if the algorithm accepts empty input, changes {@code null} to {@code ""}; if not, throws {@link IllegalArgumentException}).
+ *
+ * @param input the input to validate
+ * @return a valid input string
+ */
+ private String validateInput(String input) {
+ if (acceptsEmptyInput()) {
+ if (input == null) {
+ logger.warn("Attempting to generate a hash using {} of null input; using empty input", getAlgorithmName());
+ return "";
+ }
+ } else {
+ if (input == null || input.length() == 0) {
+ logger.warn("Attempting to generate a hash using {} of null or empty input; returning 0 length string", getAlgorithmName());
+ throw new IllegalArgumentException();
+ }
+ }
+
+ return input;
+ }
+
+ /**
+ * Returns the algorithm-specific calculated hash for the input and generates or retrieves the salt according to
+ * the configured salt length.
+ *
+ * @param input the input in raw bytes
+ * @return the hash in raw bytes
+ */
+ abstract byte[] hash(byte[] input);
+
+ /**
+ * Returns the algorithm-specific calculated hash for the input and salt.
+ *
+ * @param input the input in raw bytes
+ * @param salt the provided salt
+ * @return the hash in raw bytes
+ */
+ abstract byte[] hash(byte[] input, byte[] salt);
+}
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/Argon2CipherProvider.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/Argon2CipherProvider.java
new file mode 100644
index 0000000..84edd2b
--- /dev/null
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/Argon2CipherProvider.java
@@ -0,0 +1,295 @@
+/*
+ * 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 java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import org.apache.commons.codec.binary.Base64;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.processor.exception.ProcessException;
+import org.apache.nifi.security.util.EncryptionMethod;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class Argon2CipherProvider extends RandomIVPBECipherProvider {
+ private static final Logger logger = LoggerFactory.getLogger(Argon2CipherProvider.class);
+
+ private static final int DEFAULT_PARALLELISM = Argon2SecureHasher.DEFAULT_PARALLELISM;
+ private static final int DEFAULT_MEMORY = Argon2SecureHasher.DEFAULT_MEMORY;
+ private static final int DEFAULT_ITERATIONS = Argon2SecureHasher.DEFAULT_ITERATIONS;
+ private static final int DEFAULT_SALT_LENGTH = 16;
+
+ // Using Integer vs. int to allow for unsigned 32b (values can exceed Integer.MAX_VALUE)
+ private final Integer memory;
+ private final int parallelism;
+ private final Integer iterations;
+
+ // Argon2 full salt pattern: $argon2id$v=19$m=1024,t=3,p=1$ftvICs8WpASuN3FnRIDcRA$
+ private static final Pattern ARGON2_SALT_FORMAT = Pattern.compile("^\\$argon2id\\$v=19\\$m=\\d+,t=\\d+,p=\\d+\\$[\\w\\+\\/]{22}(?=\\$?)$");
+
+ /**
+ * Instantiates an Argon2 cipher provider using the default cost parameters
+ * {@code memory = }{@link #DEFAULT_MEMORY},
+ * {@code parallelism = }{@link #DEFAULT_PARALLELISM},
+ * {@code iterations = }{@link #DEFAULT_ITERATIONS}). A static salt is also used.
+ */
+ public Argon2CipherProvider() {
+ this(DEFAULT_MEMORY, DEFAULT_PARALLELISM, DEFAULT_ITERATIONS);
+ }
+
+ /**
+ * Instantiates an Argon2 cipher provider using the provided cost parameters.
+ *
+ * @param memory the integer number of KiB used ({@code 8p to 2^32 - 1})
+ * @param parallelism degree of parallelism ({@code 1 to 2^24 - 1})
+ * @param iterations number of iterations ({@code 1 to 2^32 - 1})
+ */
+ public Argon2CipherProvider(Integer memory, int parallelism, Integer iterations) {
+ this.memory = memory;
+ this.parallelism = parallelism;
+ this.iterations = iterations;
+ if (memory < DEFAULT_MEMORY) {
+ logger.warn("The provided memory size {} KiB is below the recommended minimum {} KiB", memory, DEFAULT_MEMORY);
+ }
+ if (parallelism < DEFAULT_PARALLELISM) {
+ logger.warn("The provided parallelization factor {} is below the recommended minimum {}", parallelism, DEFAULT_PARALLELISM);
+ }
+ if (iterations < DEFAULT_ITERATIONS) {
+ logger.warn("The provided iterations count {} is below the recommended minimum {}", iterations, DEFAULT_ITERATIONS);
+ }
+ }
+
+ /**
+ * Returns an initialized cipher for the specified algorithm. The key is derived by the KDF of the implementation. The IV is provided externally to allow for non-deterministic IVs, as IVs
+ * deterministically derived from the password are a potential vulnerability and compromise semantic security. See
+ * <a href="http://crypto.stackexchange.com/a/3970/12569">Ilmari Karonen's answer on Crypto Stack Exchange</a>
+ *
+ * @param encryptionMethod the {@link EncryptionMethod}
+ * @param password the secret input
+ * @param salt the complete salt (e.g. {@code "$argon2id$v=19$m=1024,t=3,p=1$ftvICs8WpASuN3FnRIDcRA$eB912UtYgZjKdwK64V7pfmMiDbsaPK+hts6H6cSHl3I".getBytes(StandardCharsets.UTF_8)})
+ * @param iv the IV
+ * @param keyLength the desired key length in bits
+ * @param encryptMode true for encrypt, false for decrypt
+ * @return the initialized cipher
+ * @throws Exception if there is a problem initializing the cipher
+ */
+ @Override
+ public Cipher getCipher(EncryptionMethod encryptionMethod, String password, byte[] salt, byte[] iv, int keyLength, boolean encryptMode) throws Exception {
+ try {
+ return getInitializedCipher(encryptionMethod, password, salt, iv, keyLength, encryptMode);
+ } catch (IllegalArgumentException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new ProcessException("Error initializing the cipher", e);
+ }
+ }
+
+ @Override
+ Logger getLogger() {
+ return logger;
+ }
+
+ /**
+ * Returns an initialized cipher for the specified algorithm. The key (and IV if necessary) are derived by the KDF of the implementation.
+ * <p>
+ * The IV can be retrieved by the calling method using {@link Cipher#getIV()}.
+ *
+ * @param encryptionMethod the {@link EncryptionMethod}
+ * @param password the secret input
+ * @param salt the complete salt (e.g. {@code "$argon2id$v=19$m=1024,t=3,p=1$ftvICs8WpASuN3FnRIDcRA$eB912UtYgZjKdwK64V7pfmMiDbsaPK+hts6H6cSHl3I".getBytes(StandardCharsets.UTF_8)})
+ * @param keyLength the desired key length in bits
+ * @param encryptMode true for encrypt, false for decrypt
+ * @return the initialized cipher
+ * @throws Exception if there is a problem initializing the cipher
+ */
+ @Override
+ public Cipher getCipher(EncryptionMethod encryptionMethod, String password, byte[] salt, int keyLength, boolean encryptMode) throws Exception {
+ return getCipher(encryptionMethod, password, salt, new byte[0], keyLength, encryptMode);
+ }
+
+ protected Cipher getInitializedCipher(EncryptionMethod encryptionMethod, String password, byte[] salt, byte[] iv, int keyLength, boolean encryptMode) throws Exception {
+ if (encryptionMethod == null) {
+ throw new IllegalArgumentException("The encryption method must be specified");
+ }
+ if (!encryptionMethod.isCompatibleWithStrongKDFs()) {
+ throw new IllegalArgumentException(encryptionMethod.name() + " is not compatible with Argon2");
+ }
+
+ if (StringUtils.isEmpty(password)) {
+ throw new IllegalArgumentException("Encryption with an empty password is not supported");
+ }
+
+ String algorithm = encryptionMethod.getAlgorithm();
+
+ final String cipherName = CipherUtility.parseCipherFromAlgorithm(algorithm);
+ if (!CipherUtility.isValidKeyLength(keyLength, cipherName)) {
+ throw new IllegalArgumentException(keyLength + " is not a valid key length for " + cipherName);
+ }
+
+ String saltString = new String(salt, StandardCharsets.UTF_8);
+ byte[] rawSalt = new byte[getDefaultSaltLength()];
+ int memory;
+ int parallelism;
+ int iterations;
+ if (isArgon2FormattedSalt(saltString)) {
+ List<Integer> params = new ArrayList<>(3);
+ parseSalt(saltString, rawSalt, params);
+ memory = params.get(0);
+ iterations = params.get(1);
+ parallelism = params.get(2);
+ } else {
+ rawSalt = salt;
+ memory = getMemory();
+ iterations = getIterations();
+ parallelism = getParallelism();
+ }
+
+ Argon2SecureHasher argon2SecureHasher = new Argon2SecureHasher(keyLength / 8, memory, parallelism, iterations);
+ try {
+ byte[] keyBytes = argon2SecureHasher.hashRaw(password.getBytes(StandardCharsets.UTF_8), rawSalt);
+ SecretKey tempKey = new SecretKeySpec(keyBytes, algorithm);
+
+ KeyedCipherProvider keyedCipherProvider = new AESKeyedCipherProvider();
+ return keyedCipherProvider.getCipher(encryptionMethod, tempKey, iv, encryptMode);
+ } catch (IllegalArgumentException e) {
+ if (e.getMessage().contains("The salt length")) {
+ throw new IllegalArgumentException("The raw salt must be greater than or equal to 8 bytes", e);
+ } else {
+ logger.error("Encountered an error generating the Argon2 hash", e);
+ throw e;
+ }
+ }
+ }
+
+ /**
+ * Returns the raw salt contained in the provided Argon2 salt string.
+ *
+ * @param argon2Salt the full Argon2 salt
+ * @return the raw salt decoded from Base64
+ */
+ public static byte[] extractRawSaltFromArgon2Salt(String argon2Salt) {
+ final String[] saltComponents = argon2Salt.split("\\$");
+ // saltComponents = [(optional empty before leading $), argon2id, v=19, m=1024,t=3,p=1, ftvICs8WpASuN3FnRIDcRA]
+ if (saltComponents.length < 4) {
+ throw new IllegalArgumentException("Could not parse salt");
+ }
+ return Base64.decodeBase64(saltComponents[saltComponents.length - 1]);
+ }
+
+ /**
+ * Returns {@code true} if the salt string is a valid Argon2 salt string ({@code $argon2id$v=19$m=4096,t=3,p=1$abcdefghi..{22}}).
+ *
+ * @param salt the salt string to evaluate
+ * @return true if valid Argon2 salt
+ */
+ public static boolean isArgon2FormattedSalt(String salt) {
+ if (salt == null || salt.length() == 0) {
+ throw new IllegalArgumentException("The salt cannot be empty. To generate a salt, use Argon2CipherProvider#generateSalt()");
+ }
+ Matcher matcher = ARGON2_SALT_FORMAT.matcher(salt);
+ return matcher.find();
+ }
+
+ private void parseSalt(String argon2Salt, byte[] rawSalt, List<Integer> params) {
+ if (StringUtils.isEmpty(argon2Salt)) {
+ throw new IllegalArgumentException("Cannot parse empty salt");
+ }
+
+ /** Salt format is $argon2id$v=19$m=memory,t=iterations,p=parallelism$saltB64$hash */
+ byte[] salt = extractRawSaltFromArgon2Salt(argon2Salt);
+
+ if (rawSalt.length < salt.length) {
+ byte[] tempBytes = new byte[salt.length];
+ System.arraycopy(rawSalt, 0, tempBytes, 0, rawSalt.length);
+ rawSalt = tempBytes;
+ }
+ System.arraycopy(salt, 0, rawSalt, 0, rawSalt.length);
+
+ if (params == null) {
+ params = new ArrayList<>(3);
+ }
+
+ final String[] saltComponents = argon2Salt.split("\\$");
+ // saltComponents = [, argon2id, v=19, m=1024,t=4,p=1, hiKyaQbZyQBmCmD1zGcyMw, rc+ec+/hQeBcwzjH+OEmUtaTUqhZYKN4ZKJtWzFZYjQ]
+ final Map<String, String> saltParams = Arrays.stream(saltComponents[3].split(","))
+ .collect(Collectors.toMap(
+ pair -> pair.split("=")[0],
+ pair -> pair.split("=")[1])
+ );
+ params.add(Integer.parseInt(saltParams.get("m")));
+ params.add(Integer.parseInt(saltParams.get("t")));
+ params.add(Integer.parseInt(saltParams.get("p")));
+ }
+
+ @Override
+ public byte[] generateSalt() {
+ byte[] rawSalt = new byte[DEFAULT_SALT_LENGTH];
+ new SecureRandom().nextBytes(rawSalt);
+
+ // TODO: Handle 2i and 2d implementations
+ // Form the "full salt" containing the embedded algorithm, version, cost params, and raw salt
+ return formSalt(rawSalt, getMemory(), getIterations(), getParallelism()).getBytes(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Returns the formatted Argon2 salt string given the provided parameters.
+ * <p>
+ * {@code $argon2id$v=19$m=4096,t=3,p=1$abcdefABCDEF0123456789}
+ *
+ * @param rawSalt the salt bytes
+ * @param memory the memory cost in KiB
+ * @param iterations the iterations
+ * @param parallelism the parallelism factor
+ * @return the formatted salt string
+ */
+ public static String formSalt(byte[] rawSalt, int memory, int iterations, int parallelism) {
+ StringBuilder sb = new StringBuilder("$argon2id$");
+ sb.append("v=19").append("$");
+ sb.append("m=").append(memory).append(",");
+ sb.append("t=").append(iterations).append(",");
+ sb.append("p=").append(parallelism).append("$");
+ sb.append(CipherUtility.encodeBase64NoPadding(rawSalt));
+ return sb.toString();
+ }
+
+ @Override
+ public int getDefaultSaltLength() {
+ return DEFAULT_SALT_LENGTH;
+ }
+
+ protected int getMemory() {
+ return memory;
+ }
+
+ protected int getParallelism() {
+ return parallelism;
+ }
+
+ protected int getIterations() {
+ return iterations;
+ }
+}
\ No newline at end of file
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/Argon2SecureHasher.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/Argon2SecureHasher.java
index 7ae6dc3..ca8a510 100644
--- a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/Argon2SecureHasher.java
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/Argon2SecureHasher.java
@@ -16,12 +16,9 @@
*/
package org.apache.nifi.security.util.crypto;
-import java.nio.charset.StandardCharsets;
-import java.security.SecureRandom;
import java.util.concurrent.TimeUnit;
import org.bouncycastle.crypto.generators.Argon2BytesGenerator;
import org.bouncycastle.crypto.params.Argon2Parameters;
-import org.bouncycastle.util.encoders.Base64;
import org.bouncycastle.util.encoders.Hex;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -39,17 +36,17 @@ import org.slf4j.LoggerFactory;
* potentially different {@code nifi.sensitive.props.key} values) during flow inheritance
* (see {@code FingerprintFactory}).
*/
-public class Argon2SecureHasher implements SecureHasher {
+public class Argon2SecureHasher extends AbstractSecureHasher {
private static final Logger logger = LoggerFactory.getLogger(Argon2SecureHasher.class);
private static final int DEFAULT_HASH_LENGTH = 32;
- private static final int DEFAULT_PARALLELISM = 1;
- private static final int DEFAULT_MEMORY = 1 << 12;
- private static final int DEFAULT_ITERATIONS = 3;
+ public static final int DEFAULT_PARALLELISM = 8;
+ public static final int DEFAULT_MEMORY = 1 << 16;
+ public static final int DEFAULT_ITERATIONS = 5;
private static final int DEFAULT_SALT_LENGTH = 16;
private static final int MIN_MEMORY_SIZE_KB = 8;
private static final int MIN_PARALLELISM = 1;
- private static final long MAX_PARALLELISM = Math.round(Math.pow(2, 24)) - 1;
+ private static final int MAX_PARALLELISM = Double.valueOf(Math.pow(2, 24)).intValue() - 1;
private static final int MIN_HASH_LENGTH = 4;
private static final int MIN_ITERATIONS = 1;
private static final int MIN_SALT_LENGTH = 8;
@@ -59,15 +56,6 @@ public class Argon2SecureHasher implements SecureHasher {
private final Integer memory;
private final int parallelism;
private final Integer iterations;
- private final Integer saltLength;
-
- private boolean usingStaticSalt;
-
- // A 16 byte salt (nonce) is recommended for password hashing
- private static final byte[] STATIC_SALT = "NiFi Static Salt".getBytes(StandardCharsets.UTF_8);
-
- // Upper boundary for several cost parameters
- private static final long UPPER_BOUNDARY = Math.round(Math.pow(2, 32)) - 1;
/**
* Instantiates an Argon2 secure hasher using the default cost parameters
@@ -81,12 +69,24 @@ public class Argon2SecureHasher implements SecureHasher {
}
/**
+ * Instantiates an Argon2 secure hasher using the provided hash length and default cost parameters
+ * ({@code memory = }{@link #DEFAULT_MEMORY},
+ * {@code parallelism = }{@link #DEFAULT_PARALLELISM},
+ * {@code iterations = }{@link #DEFAULT_ITERATIONS}). A static salt is also used.
+ *
+ * @param hashLength the desired hash output length in bytes
+ */
+ public Argon2SecureHasher(Integer hashLength) {
+ this(hashLength, DEFAULT_MEMORY, DEFAULT_PARALLELISM, DEFAULT_ITERATIONS, 0);
+ }
+
+ /**
* Instantiates an Argon2 secure hasher using the provided cost parameters. A static
* {@link #DEFAULT_SALT_LENGTH} byte salt will be generated on every hash request.
* {@link Integer} is used instead of {@code int} for parameters which have a max value of {@code 2^32 - 1} to allow for unsigned integers exceeding {@link Integer#MAX_VALUE}.
*
* @param hashLength the output length in bytes ({@code 4 to 2^32 - 1})
- * @param memory the integer number of KB used ({@code 8p to 2^32 - 1})
+ * @param memory the integer number of KiB used ({@code 8p to 2^32 - 1})
* @param parallelism degree of parallelism ({@code 1 to 2^24 - 1})
* @param iterations number of iterations ({@code 1 to 2^32 - 1})
*/
@@ -100,13 +100,12 @@ public class Argon2SecureHasher implements SecureHasher {
* {@link Integer} is used instead of {@code int} for parameters which have a max value of {@code 2^32 - 1} to allow for unsigned integers exceeding {@link Integer#MAX_VALUE}.
*
* @param hashLength the output length in bytes ({@code 4 to 2^32 - 1})
- * @param memory the integer number of KB used ({@code 8p to 2^32 - 1})
+ * @param memory the integer number of KiB used ({@code 8p to 2^32 - 1})
* @param parallelism degree of parallelism ({@code 1 to 2^24 - 1})
* @param iterations number of iterations ({@code 1 to 2^32 - 1})
* @param saltLength the salt length in bytes {@code 8 to 2^32 - 1})
*/
public Argon2SecureHasher(Integer hashLength, Integer memory, int parallelism, Integer iterations, Integer saltLength) {
-
validateParameters(hashLength, memory, parallelism, iterations, saltLength);
this.hashLength = hashLength;
@@ -121,13 +120,12 @@ public class Argon2SecureHasher implements SecureHasher {
* Enforces valid Argon2 secure hasher cost parameters are provided.
*
* @param hashLength the output length in bytes ({@code 4 to 2^32 - 1})
- * @param memory the integer number of KB used ({@code 8p to 2^32 - 1})
+ * @param memory the integer number of KiB used ({@code 8p to 2^32 - 1})
* @param parallelism degree of parallelism ({@code 1 to 2^24 - 1})
* @param iterations number of iterations ({@code 1 to 2^32 - 1})
* @param saltLength the salt length in bytes {@code 8 to 2^32 - 1})
*/
private void validateParameters(Integer hashLength, Integer memory, int parallelism, Integer iterations, Integer saltLength) {
-
if (!isHashLengthValid(hashLength)) {
logger.error("The provided hash length {} is outside the boundary of 4 to 2^32 - 1.", hashLength);
throw new IllegalArgumentException("Invalid hash length is not within the hashLength boundary.");
@@ -145,43 +143,27 @@ public class Argon2SecureHasher implements SecureHasher {
throw new IllegalArgumentException("Invalid iteration count exceeds the iterations boundary.");
}
- if (saltLength > 0) {
- if (!isSaltLengthValid(saltLength)) {
- logger.error("The salt length {} is outside the boundary of 8 to 2^32 - 1.", saltLength);
- throw new IllegalArgumentException("Invalid salt length exceeds the saltLength boundary.");
- }
- this.usingStaticSalt = false;
- } else {
- this.usingStaticSalt = true;
- logger.debug("Configured to use static salt");
- }
+ initializeSalt(saltLength);
}
/**
- * Returns {@code true} if this instance is configured to use a static salt.
+ * Returns the algorithm-specific name for logging and messages.
*
- * @return true if all hashes will be generated using a static salt
+ * @return the algorithm name
*/
- public boolean isUsingStaticSalt() {
- return usingStaticSalt;
+ @Override
+ String getAlgorithmName() {
+ return "Argon2";
}
/**
- * Returns a salt to use. If using a static salt (see {@link #isUsingStaticSalt()}),
- * this return value will be identical across every invocation. If using a dynamic salt,
- * it will be {@link #saltLength} bytes of a securely-generated random value.
+ * Returns {@code true} if the algorithm can accept empty (non-{@code null}) inputs.
*
- * @return the salt value
+ * @return the true if {@code ""} is allowable input
*/
- byte[] getSalt() {
- if (isUsingStaticSalt()) {
- return STATIC_SALT;
- } else {
- SecureRandom sr = new SecureRandom();
- byte[] salt = new byte[saltLength];
- sr.nextBytes(salt);
- return salt;
- }
+ @Override
+ boolean acceptsEmptyInput() {
+ return true;
}
/**
@@ -191,9 +173,6 @@ public class Argon2SecureHasher implements SecureHasher {
* @return true if hashLength is within boundaries
*/
public static boolean isHashLengthValid(Integer hashLength) {
- if (hashLength < DEFAULT_HASH_LENGTH) {
- logger.warn("The provided hash length {} is below the recommended minimum {}.", hashLength, DEFAULT_HASH_LENGTH);
- }
return hashLength >= MIN_HASH_LENGTH && hashLength <= UPPER_BOUNDARY;
}
@@ -237,80 +216,68 @@ public class Argon2SecureHasher implements SecureHasher {
}
/**
- * Returns whether the provided salt length (saltLength) is within boundaries. The lower bound >= 8 and the
- * upper bound <= 2^32 - 1.
- * @param saltLength the salt length in bytes
- * @return true if saltLength is within boundaries
+ * Returns the algorithm-specific default salt length in bytes.
+ *
+ * @return the default salt length
*/
- public static boolean isSaltLengthValid(Integer saltLength) {
- if (saltLength == 0) {
- logger.debug("The provided salt length 0 indicates a static salt of {} bytes", DEFAULT_SALT_LENGTH);
- return true;
- }
- if (saltLength < DEFAULT_SALT_LENGTH) {
- logger.warn("The provided dynamic salt length {} is below the recommended minimum {}", saltLength, DEFAULT_SALT_LENGTH);
- }
- return saltLength >= MIN_SALT_LENGTH && saltLength <= UPPER_BOUNDARY;
+ @Override
+ int getDefaultSaltLength() {
+ return DEFAULT_SALT_LENGTH;
}
/**
- * Returns a String representation of {@code Argon2(input)} in hex-encoded format.
+ * Returns the algorithm-specific minimum salt length in bytes.
*
- * @param input the input
- * @return the hex-encoded hash
+ * @return the min salt length
*/
@Override
- public String hashHex(String input) {
- if (input == null) {
- logger.warn("Attempting to generate an Argon2 hash of null input; using empty input");
- input = "";
- }
-
- return Hex.toHexString(hash(input.getBytes(StandardCharsets.UTF_8)));
+ int getMinSaltLength() {
+ return MIN_SALT_LENGTH;
}
/**
- * Returns a String representation of {@code Argon2(input)} in Base 64-encoded format.
+ * Returns the algorithm-specific maximum salt length in bytes.
*
- * @param input the input
- * @return the Base 64-encoded hash
+ * @return the max salt length
*/
@Override
- public String hashBase64(String input) {
- if (input == null) {
- logger.warn("Attempting to generate an Argon2 hash of null input; using empty input");
- input = "";
- }
-
- return Base64.toBase64String(hash(input.getBytes(StandardCharsets.UTF_8)));
+ int getMaxSaltLength() {
+ return Integer.MAX_VALUE;
}
/**
- * Returns a byte[] representation of {@code Argon2(input)}.
+ * Internal method to hash the raw bytes.
*
- * @param input the input
- * @return the hash
+ * @param input the raw bytes to hash (can be length 0)
+ * @return the generated hash
*/
- @Override
- public byte[] hashRaw(byte[] input) {
- return hash(input);
+ byte[] hash(byte[] input) {
+ // Contains only the raw salt
+ byte[] rawSalt = getSalt();
+
+ return hash(input, rawSalt);
}
/**
* Internal method to hash the raw bytes.
*
* @param input the raw bytes to hash (can be length 0)
+ * @param rawSalt the raw bytes to salt
* @return the generated hash
*/
- private byte[] hash(byte[] input) {
- byte[] salt = getSalt();
- byte[] hash = new byte[hashLength];
- logger.debug("Creating {} byte Argon2 hash with salt [{}]", hashLength, Hex.toHexString(salt));
+ byte[] hash(byte[] input, byte[] rawSalt) {
+ logger.debug("Creating {} byte Argon2 hash with salt [{}]", hashLength, Hex.toHexString(rawSalt));
+
+ if (!isSaltLengthValid(rawSalt.length)) {
+ throw new IllegalArgumentException("The salt length (" + rawSalt.length + " bytes) is invalid");
+ }
+
+ byte[] hash = new byte[hashLength];
final long startNanos = System.nanoTime();
Argon2Parameters params = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
- .withSalt(salt)
+ .withSalt(rawSalt)
.withParallelism(parallelism)
.withMemoryAsKB(memory)
.withIterations(iterations)
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/BcryptCipherProvider.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/BcryptCipherProvider.java
index 7a22263..531c14e 100644
--- a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/BcryptCipherProvider.java
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/BcryptCipherProvider.java
@@ -16,23 +16,23 @@
*/
package org.apache.nifi.security.util.crypto;
-import at.favre.lib.crypto.bcrypt.BCrypt;
import at.favre.lib.crypto.bcrypt.Radix64Encoder;
-import org.apache.commons.lang3.StringUtils;
-import org.apache.nifi.processor.exception.ProcessException;
-import org.apache.nifi.security.util.EncryptionMethod;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import javax.crypto.Cipher;
-import javax.crypto.SecretKey;
-import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.NoSuchProviderException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.processor.exception.ProcessException;
+import org.apache.nifi.security.util.EncryptionMethod;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
public class BcryptCipherProvider extends RandomIVPBECipherProvider {
private static final Logger logger = LoggerFactory.getLogger(BcryptCipherProvider.class);
@@ -45,6 +45,7 @@ public class BcryptCipherProvider extends RandomIVPBECipherProvider {
private static final int DEFAULT_SALT_LENGTH = 16;
private static final Pattern BCRYPT_SALT_FORMAT = Pattern.compile("^\\$\\d\\w\\$\\d{2}\\$[\\w\\/\\.]{22}");
+ private static final String BCRYPT_SALT_FORMAT_MSG = "The salt must be of the format $2a$10$gUVbkVzp79H8YaCOsCVZNu. To generate a salt, use BcryptCipherProvider#generateSalt()";
/**
* Instantiates a Bcrypt cipher provider with the default work factor 12 (2^12 key expansion rounds).
@@ -65,6 +66,11 @@ public class BcryptCipherProvider extends RandomIVPBECipherProvider {
}
}
+ @Override
+ Logger getLogger() {
+ return logger;
+ }
+
/**
* Returns an initialized cipher for the specified algorithm. The key is derived by the KDF of the implementation. The IV is provided externally to allow for non-deterministic IVs, as IVs
* deterministically derived from the password are a potential vulnerability and compromise semantic security. See
@@ -81,8 +87,12 @@ public class BcryptCipherProvider extends RandomIVPBECipherProvider {
*/
@Override
public Cipher getCipher(EncryptionMethod encryptionMethod, String password, byte[] salt, byte[] iv, int keyLength, boolean encryptMode) throws Exception {
+ return createCipherAndHandleExceptions(encryptionMethod, password, salt, iv, keyLength, encryptMode, false);
+ }
+
+ private Cipher createCipherAndHandleExceptions(EncryptionMethod encryptionMethod, String password, byte[] salt, byte[] iv, int keyLength, boolean encryptMode, boolean useLegacyKeyDerivation) {
try {
- return getInitializedCipher(encryptionMethod, password, salt, iv, keyLength, encryptMode);
+ return getInitializedCipher(encryptionMethod, password, salt, iv, keyLength, encryptMode, useLegacyKeyDerivation);
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
@@ -90,11 +100,6 @@ public class BcryptCipherProvider extends RandomIVPBECipherProvider {
}
}
- @Override
- Logger getLogger() {
- return logger;
- }
-
/**
* Returns an initialized cipher for the specified algorithm. The key (and IV if necessary) are derived by the KDF of the implementation.
* <p>
@@ -113,7 +118,27 @@ public class BcryptCipherProvider extends RandomIVPBECipherProvider {
return getCipher(encryptionMethod, password, salt, new byte[0], keyLength, encryptMode);
}
- protected Cipher getInitializedCipher(EncryptionMethod encryptionMethod, String password, byte[] salt, byte[] iv, int keyLength, boolean encryptMode) throws Exception {
+ /**
+ * Returns a {@link Cipher} instance in {@code Cipher.DECRYPT_MODE} configured with the provided inputs and using <em>the
+ * legacy key derivation process for {@code Bcrypt}</em> where the complete Bcrypt hash output (including algorithm, work
+ * factor, and salt) was used as the input to the key stretching SHA-512 digest function. This is only used for
+ * backward-compatibility decryptions for NiFi versions prior to 1.12.0. All encryption operations moving forward use the
+ * correct key derivation process.
+ *
+ * @param encryptionMethod the {@link EncryptionMethod}
+ * @param password the secret input
+ * @param salt the complete salt (e.g. {@code "$2a$10$gUVbkVzp79H8YaCOsCVZNu".getBytes(StandardCharsets.UTF_8)})
+ * @param iv the Initialization Vector in bits
+ * @param keyLength the desired key length in bits
+ * @return the initialized cipher using the legacy key derivation process
+ * @throws Exception if there is a problem initializing the cipher
+ */
+ public Cipher getLegacyDecryptCipher(EncryptionMethod encryptionMethod, String password, byte[] salt, byte[] iv, int keyLength) {
+ return createCipherAndHandleExceptions(encryptionMethod, password, salt, iv, keyLength, false, true);
+ }
+
+ protected Cipher getInitializedCipher(EncryptionMethod encryptionMethod, String password, byte[] salt, byte[] iv,
+ int keyLength, boolean encryptMode, boolean useLegacyKeyDerivation) throws Exception {
if (encryptionMethod == null) {
throw new IllegalArgumentException("The encryption method must be specified");
}
@@ -133,32 +158,104 @@ public class BcryptCipherProvider extends RandomIVPBECipherProvider {
throw new IllegalArgumentException(keyLength + " is not a valid key length for " + cipherName);
}
- byte[] rawSalt = extractRawSalt(salt);
- String hash = new String(BCrypt.withDefaults().hash(workFactor, rawSalt, password.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8);
+ if (salt == null || salt.length == 0) {
+ throw new IllegalArgumentException(BCRYPT_SALT_FORMAT_MSG);
+ }
+ String saltString = new String(salt, StandardCharsets.UTF_8);
+ byte[] rawSalt = new byte[DEFAULT_SALT_LENGTH];
+ int workFactor = 0;
+ if (isBcryptFormattedSalt(saltString)) {
+ // parseSalt will extract workFactor from salt
+ workFactor = parseSalt(saltString, rawSalt);
+ } else {
+ throw new IllegalArgumentException(BCRYPT_SALT_FORMAT_MSG);
+ }
+
+ try {
+ SecretKey tempKey = deriveKey(password, keyLength, algorithm, provider, rawSalt, workFactor, useLegacyKeyDerivation);
+ KeyedCipherProvider keyedCipherProvider = new AESKeyedCipherProvider();
+ return keyedCipherProvider.getCipher(encryptionMethod, tempKey, iv, encryptMode);
+ } catch (IllegalArgumentException e) {
+ if (e.getMessage().contains("salt must be exactly")) {
+ throw new IllegalArgumentException(BCRYPT_SALT_FORMAT_MSG, e);
+ } else if (e.getMessage().contains("The salt length")) {
+ throw new IllegalArgumentException("The raw salt must be greater than or equal to 16 bytes", e);
+ } else {
+ logger.error("Encountered an error generating the Bcrypt hash", e);
+ throw e;
+ }
+ }
+ }
- /* The SHA-512 hash is required in order to derive a key longer than 184 bits (the resulting size of the Bcrypt hash) and ensuring the avalanche effect causes higher key entropy (if all
- derived keys follow a consistent pattern, it weakens the strength of the encryption) */
+ private SecretKey deriveKey(String password, int keyLength, String algorithm, String provider, byte[] rawSalt,
+ int workFactor, boolean useLegacyKeyDerivation) throws NoSuchAlgorithmException, NoSuchProviderException {
+ /* The SHA-512 hash is required in order to derive a key longer than 184 bits (the resulting size of the Bcrypt hash) and ensuring the avalanche effect causes higher key entropy (if all
+derived keys follow a consistent pattern, it weakens the strength of the encryption) */
MessageDigest digest = MessageDigest.getInstance("SHA-512", provider);
- byte[] dk = digest.digest(hash.getBytes(StandardCharsets.UTF_8));
- dk = Arrays.copyOf(dk, keyLength / 8);
- SecretKey tempKey = new SecretKeySpec(dk, algorithm);
+ BcryptSecureHasher bcryptSecureHasher = new BcryptSecureHasher(workFactor);
+ byte[] fullHashOutputBytes = bcryptSecureHasher.hashRaw(password.getBytes(StandardCharsets.UTF_8), rawSalt);
+ byte[] derivedKeyBytes;
- KeyedCipherProvider keyedCipherProvider = new AESKeyedCipherProvider();
- return keyedCipherProvider.getCipher(encryptionMethod, tempKey, iv, encryptMode);
+ // Depending on the legacy key derivation process indicator, run the digest over the full or partial hash output
+ if (useLegacyKeyDerivation) {
+ // The "legacy" process included the algorithm, work factor, and salt in the digest input
+ derivedKeyBytes = digest.digest(fullHashOutputBytes);
+ logger.warn("Using legacy key derivation process for backward compatibility (digest run on full {} bytes of Bcrypt hash output)", fullHashOutputBytes.length);
+ } else {
+ // Only digest "hash" (last 31 bytes) not the algorithm, version, work factor, and salt (first 29 bytes)
+ final int HASH_OUTPUT_START = 29;
+ byte[] hashBytes = Arrays.copyOfRange(fullHashOutputBytes, HASH_OUTPUT_START, fullHashOutputBytes.length);
+ derivedKeyBytes = digest.digest(hashBytes);
+ }
+
+ derivedKeyBytes = Arrays.copyOf(derivedKeyBytes, keyLength / 8);
+ return new SecretKeySpec(derivedKeyBytes, algorithm);
}
- private static String formatSaltForBcrypt(byte[] salt) {
- if (salt == null || salt.length == 0) {
+ /**
+ * Returns {@code true} if the salt string is a valid Bcrypt salt string ({@code $2a$10$abcdefghi..{22}}).
+ *
+ * @param salt the salt string to evaluate
+ * @return true if valid Bcrypt salt
+ */
+ public static boolean isBcryptFormattedSalt(String salt) {
+ if (salt == null || salt.length() == 0) {
throw new IllegalArgumentException("The salt cannot be empty. To generate a salt, use BcryptCipherProvider#generateSalt()");
}
+ Matcher matcher = BCRYPT_SALT_FORMAT.matcher(salt);
+ return matcher.find();
+ }
- String rawSalt = new String(salt, StandardCharsets.UTF_8);
- Matcher matcher = BCRYPT_SALT_FORMAT.matcher(rawSalt);
+ private int parseSalt(String bcryptSalt, byte[] rawSalt) {
+ if (StringUtils.isEmpty(bcryptSalt)) {
+ throw new IllegalArgumentException("Cannot parse empty salt");
+ }
+
+ /** Salt format is $2a$10$saltB64 */
+ byte[] salt = extractRawSalt(bcryptSalt);
+ if (rawSalt.length < salt.length) {
+ byte[] tempBytes = new byte[salt.length];
+ System.arraycopy(rawSalt, 0, tempBytes, 0, rawSalt.length);
+ rawSalt = tempBytes;
+ }
+ // Copy salt from full bcryptSalt into rawSalt byte[]
+ System.arraycopy(salt, 0, rawSalt, 0, salt.length);
- if (matcher.find()) {
- return rawSalt;
+ // Parse the workfactor and return
+ final String[] saltComponents = bcryptSalt.split("\\$");
+ return Integer.parseInt(saltComponents[2]);
+ }
+
+ public static String formatSaltForBcrypt(byte[] salt, int workFactor) {
+ String rawSaltString = new String(salt, StandardCharsets.UTF_8);
+ if (isBcryptFormattedSalt(rawSaltString)) {
+ return rawSaltString;
} else {
- throw new IllegalArgumentException("The salt must be of the format $2a$10$gUVbkVzp79H8YaCOsCVZNu. To generate a salt, use BcryptCipherProvider#generateSalt()");
+ // TODO: This library allows for 2a, 2b, and 2y versions so this should be changed to be configurable
+ String saltString = "$2a$" +
+ StringUtils.leftPad(String.valueOf(workFactor), 2, "0") +
+ "$" + new String(new Radix64Encoder.Default().encode(salt), StandardCharsets.UTF_8);
+ return saltString;
}
}
@@ -172,10 +269,7 @@ public class BcryptCipherProvider extends RandomIVPBECipherProvider {
byte[] salt = new byte[DEFAULT_SALT_LENGTH];
SecureRandom sr = new SecureRandom();
sr.nextBytes(salt);
- // TODO: This library allows for 2a, 2b, and 2y versions so this should be changed to be configurable
- String saltString = "$2a$" +
- StringUtils.leftPad(String.valueOf(workFactor), 2, "0") +
- "$" + new String(new Radix64Encoder.Default().encode(salt), StandardCharsets.UTF_8);
+ String saltString = formatSaltForBcrypt(salt, getWorkFactor());
return saltString.getBytes(StandardCharsets.UTF_8);
}
@@ -185,31 +279,14 @@ public class BcryptCipherProvider extends RandomIVPBECipherProvider {
* @param fullSalt the Bcrypt salt sequence as bytes
* @return the raw salt (16 bytes) without Radix 64 encoding
*/
- public static byte[] extractRawSalt(byte[] fullSalt) {
- try {
- String formattedSalt = formatSaltForBcrypt(fullSalt);
- String rawSalt = formattedSalt.substring(formattedSalt.lastIndexOf("$") + 1);
- if (rawSalt.length() != 22) {
- throw new IllegalArgumentException("The formatted salt did not contain a raw salt");
- }
- return new Radix64Encoder.Default().decode(rawSalt.getBytes(StandardCharsets.UTF_8));
- } catch (IllegalArgumentException e) {
- logger.warn("Unable to extract a raw salt from bcrypt salt {}", new String(fullSalt, StandardCharsets.UTF_8));
- throw e;
- }
- }
-
- /**
- * Returns the raw salt as a {@code byte[]} extracted from the Bcrypt formatted salt String.
- *
- * @param fullSalt the Bcrypt salt sequence
- * @return the raw salt (16 bytes)
- */
public static byte[] extractRawSalt(String fullSalt) {
- if (fullSalt == null) {
- return new byte[0];
+ final String[] saltComponents = fullSalt.split("\\$");
+ if (saltComponents.length < 4) {
+ throw new IllegalArgumentException("Could not parse salt");
}
- return extractRawSalt(fullSalt.getBytes(StandardCharsets.UTF_8));
+
+ // Bcrypt uses a custom Radix64 encoding alphabet that is not compatible with default Base64
+ return new Radix64Encoder.Default().decode(saltComponents[3].getBytes(StandardCharsets.UTF_8));
}
@Override
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/BcryptSecureHasher.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/BcryptSecureHasher.java
new file mode 100644
index 0000000..cb1c643
--- /dev/null
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/BcryptSecureHasher.java
@@ -0,0 +1,210 @@
+/*
+ * 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 at.favre.lib.crypto.bcrypt.BCrypt;
+import at.favre.lib.crypto.bcrypt.Radix64Encoder;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.codec.binary.Base64;
+import org.bouncycastle.util.encoders.Hex;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Provides an implementation of {@code Bcrypt} for secure password hashing.
+ * <p>
+ * One <strong>critical</strong> difference is that this implementation uses a
+ * <strong>static universal</strong> salt unless instructed otherwise, which provides
+ * strict determinism across nodes in a cluster. The purpose for this is to allow for
+ * blind equality comparison of sensitive values hashed on different nodes (with
+ * potentially different {@code nifi.sensitive.props.key} values) during flow inheritance
+ * (see {@code FingerprintFactory}).
+ * <p>
+ * The resulting output is referred to as a <em>hash</em> to be consistent with {@link SecureHasher} terminology.
+ */
+public class BcryptSecureHasher extends AbstractSecureHasher {
+ private static final Logger logger = LoggerFactory.getLogger(BcryptSecureHasher.class);
+
+ /**
+ * These values can be calculated automatically using the code {@see BcryptCipherProviderGroovyTest#calculateMinimumParameters} or manually updated by a maintainer
+ */
+ private static final int DEFAULT_COST = 12;
+ private static final int DEFAULT_SALT_LENGTH = 16;
+
+ private static final int MIN_COST = 4;
+ private static final int MAX_COST = 31;
+ private static final int MIN_SALT_LENGTH = 16;
+
+ private final int cost;
+
+ /**
+ * Instantiates a Bcrypt secure hasher using the default cost parameter
+ * ({@code cost = }{@link #DEFAULT_COST}
+ */
+ public BcryptSecureHasher() {
+ this(DEFAULT_COST, 0);
+ }
+
+ /**
+ * Instantiates a Bcrypt secure hasher using the provided cost parameters. A static
+ * {@link #DEFAULT_SALT_LENGTH} byte salt will be generated on every hash request.
+ *
+ * @param cost the (log) number of key expansion rounds [4..31]
+ */
+ public BcryptSecureHasher(int cost) {
+ this(cost, 0);
+ }
+
+ /**
+ * Instantiates an Bcrypt secure hasher using the provided cost parameters. A unique
+ * salt of the specified length will be generated on every hash request.
+ *
+ * @param cost the (log) number of key expansion rounds [4..31]
+ * @param saltLength the salt length in bytes {@code >= 8})
+ */
+ public BcryptSecureHasher(int cost, int saltLength) {
+ validateParameters(cost, saltLength);
+ this.cost = cost;
+ this.saltLength = saltLength;
+ }
+
+ /**
+ * Enforces valid Scrypt secure hasher cost parameters are provided.
+ *
+ * @param cost the (log) number of key expansion rounds [4..31]
+ * @param saltLength the salt length in bytes {@code >= 16})
+ */
+ private void validateParameters(Integer cost, Integer saltLength) {
+ if (!isCostValid(cost)) {
+ logger.error("The provided cost factor {} is outside the boundary of 4 to 31.", cost);
+ throw new IllegalArgumentException("Invalid cost is not within the cost factor boundary.");
+ }
+
+ initializeSalt(saltLength);
+ }
+
+ /**
+ * Returns true if the provided cost factor is within boundaries. The lower bound >= 4 and the
+ * upper bound <= 31.
+ *
+ * @param cost the (log) number of key expansion rounds [4..31]
+ * @return true if cost factor is within boundaries
+ */
+ public static boolean isCostValid(Integer cost) {
+ if (cost < DEFAULT_COST) {
+ logger.warn("The provided cost factor {} is below the recommended minimum {}.", cost, DEFAULT_COST);
+ }
+ return cost >= MIN_COST && cost <= MAX_COST;
+ }
+
+ public static String convertBcryptRadix64ToMimeBase64(String radix64) {
+ return CipherUtility.encodeBase64NoPadding(new Radix64Encoder.Default().decode(radix64.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ public static String convertMimeBase64ToBcryptRadix64(String base64) {
+ return new String(new Radix64Encoder.Default().encode(Base64.decodeBase64(base64)), StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Returns the algorithm-specific default salt length in bytes.
+ *
+ * @return the default salt length
+ */
+ @Override
+ int getDefaultSaltLength() {
+ return DEFAULT_SALT_LENGTH;
+ }
+
+ /**
+ * Returns the algorithm-specific minimum salt length in bytes.
+ *
+ * @return the min salt length
+ */
+ @Override
+ int getMinSaltLength() {
+ return MIN_SALT_LENGTH;
+ }
+
+ /**
+ * Returns the algorithm-specific maximum salt length in bytes.
+ *
+ * @return the max salt length
+ */
+ @Override
+ int getMaxSaltLength() {
+ return Integer.MAX_VALUE;
+ }
+
+ /**
+ * Returns the algorithm-specific name for logging and messages.
+ *
+ * @return the algorithm name
+ */
+ @Override
+ String getAlgorithmName() {
+ return "Bcrypt";
+ }
+
+ /**
+ * Returns {@code true} if the algorithm can accept empty (non-{@code null}) inputs.
+ *
+ * @return the true if {@code ""} is allowable input
+ */
+ @Override
+ boolean acceptsEmptyInput() {
+ return false;
+ }
+
+ /**
+ * Internal method to hash the raw bytes.
+ *
+ * @param input the raw bytes to hash (can be length 0)
+ * @return the generated hash
+ */
+ byte[] hash(byte[] input) {
+ // Contains only the raw salt
+ byte[] rawSalt = getSalt();
+
+ return hash(input, rawSalt);
+ }
+
+ /**
+ * Internal method to hash the raw bytes.
+ *
+ * @param input the raw bytes to hash (can be length 0)
+ * @param rawSalt the raw bytes to salt
+ * @return the generated hash
+ */
+ byte[] hash(byte[] input, byte[] rawSalt) {
+ logger.debug("Creating Bcrypt hash with salt [{}] ({} bytes)", Hex.toHexString(rawSalt), rawSalt.length);
+
+ if (!isSaltLengthValid(rawSalt.length)) {
+ throw new IllegalArgumentException("The salt length (" + rawSalt.length + " bytes) is invalid");
+ }
+
+ final long startNanos = System.nanoTime();
+ byte[] hash = BCrypt.withDefaults().hash(cost, rawSalt, input);
+ final long generateNanos = System.nanoTime();
+
+ final long totalDurationMillis = TimeUnit.NANOSECONDS.toMillis(generateNanos - startNanos);
+
+ logger.debug("Generated Bcrypt hash in {} ms", totalDurationMillis);
+
+ return hash;
+ }
+}
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/CipherProviderFactory.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/CipherProviderFactory.java
index 8ef388a..3272bbb 100644
--- a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/CipherProviderFactory.java
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/CipherProviderFactory.java
@@ -36,6 +36,7 @@ public class CipherProviderFactory {
registeredCipherProviders.put(KeyDerivationFunction.PBKDF2, PBKDF2CipherProvider.class);
registeredCipherProviders.put(KeyDerivationFunction.BCRYPT, BcryptCipherProvider.class);
registeredCipherProviders.put(KeyDerivationFunction.SCRYPT, ScryptCipherProvider.class);
+ registeredCipherProviders.put(KeyDerivationFunction.ARGON2, Argon2CipherProvider.class);
registeredCipherProviders.put(KeyDerivationFunction.NONE, AESKeyedCipherProvider.class);
}
@@ -47,11 +48,11 @@ public class CipherProviderFactory {
try {
return clazz.newInstance();
} catch (Exception e) {
- logger.error("Error instantiating new {} with default parameters for {}", clazz.getName(), kdf.getName());
+ logger.error("Error instantiating new {} with default parameters for {}", clazz.getName(), kdf.getKdfName());
throw new ProcessException("Error instantiating cipher provider");
}
}
- throw new IllegalArgumentException("No cipher provider registered for " + kdf.getName());
+ throw new IllegalArgumentException("No cipher provider registered for " + kdf.getKdfName());
}
}
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/CipherUtility.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/CipherUtility.java
index 5c1eb27..60d6745 100644
--- a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/CipherUtility.java
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/CipherUtility.java
@@ -20,16 +20,20 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.spec.InvalidKeySpecException;
+import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.Date;
import java.util.HashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -43,6 +47,9 @@ import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.processor.exception.ProcessException;
import org.apache.nifi.security.util.EncryptionMethod;
+import org.apache.nifi.security.util.KeyDerivationFunction;
+import org.apache.nifi.stream.io.ByteCountingInputStream;
+import org.apache.nifi.stream.io.ByteCountingOutputStream;
import org.apache.nifi.stream.io.StreamUtils;
public class CipherUtility {
@@ -265,9 +272,9 @@ public class CipherUtility {
final byte[] buffer = new byte[BUFFER_SIZE];
int len;
while ((len = in.read(buffer)) > 0) {
- final byte[] decryptedBytes = cipher.update(buffer, 0, len);
- if (decryptedBytes != null) {
- out.write(decryptedBytes);
+ final byte[] transformedBytes = cipher.update(buffer, 0, len);
+ if (transformedBytes != null) {
+ out.write(transformedBytes);
}
}
@@ -442,4 +449,112 @@ public class CipherUtility {
public static String getLoggableRepresentationOfSensitiveValue(String sensitivePropertyValue, SecureHasher secureHasher) {
return "[MASKED] (" + secureHasher.hashBase64(sensitivePropertyValue) + ")";
}
+
+ /**
+ * Returns the current timestamp in a default format. Used by many encryption operations for logging/debugging.
+ *
+ * @return the current timestamp in 'yyyy-MM-dd HH:mm:ss.SSS Z' format
+ */
+ public static String getTimestampString() {
+ Locale currentLocale = Locale.getDefault();
+ String pattern = "yyyy-MM-dd HH:mm:ss.SSS Z";
+ SimpleDateFormat formatter = new SimpleDateFormat(pattern, currentLocale);
+ Date now = new Date();
+ return formatter.format(now);
+ }
+
+ public static ByteCountingInputStream wrapStreamForCounting(InputStream inputStream) {
+ // Wrap the streams for byte counting if necessary
+ ByteCountingInputStream bcis;
+ if (!(inputStream instanceof ByteCountingInputStream)) {
+ bcis = new ByteCountingInputStream(inputStream);
+ } else {
+ bcis = (ByteCountingInputStream) inputStream;
+ }
+
+ return bcis;
+ }
+
+ public static ByteCountingOutputStream wrapStreamForCounting(OutputStream outputStream) {
+ // Wrap the streams for byte counting if necessary
+ ByteCountingOutputStream bcos;
+ if (!(outputStream instanceof ByteCountingOutputStream)) {
+ bcos = new ByteCountingOutputStream(outputStream);
+ } else {
+ bcos = (ByteCountingOutputStream) outputStream;
+ }
+
+ return bcos;
+ }
+
+ /**
+ * Returns the calculated cipher text length given the plaintext length and salt length, if any. If the salt length is > 0, the salt delimiter length ({@code 8}) is included as well.
+ *
+ * @param ptLength the plaintext length
+ * @param saltLength the salt length
+ * @return the complete cipher text, salt (optional), IV, and delimiter(s) length
+ */
+ public static int calculateCipherTextLength(int ptLength, int saltLength) {
+ int ctBlocks = Double.valueOf(Math.ceil(ptLength / 16.0)).intValue();
+ int ctLength = (ptLength % 16 == 0 ? ctBlocks + 1 : ctBlocks) * 16;
+ // IV length, Salt delimiter length, IV delimiter length
+ return ctLength + saltLength + 16 + (saltLength > 0 ? 8 : 0) + 6;
+ }
+
+ /**
+ * Returns the array index of {@code haystack} if {@code needle} is found within it. This is a sequence scanner.
+ *
+ * @param haystack the search space byte[]
+ * @param needle the sequence to find
+ * @return the first index of the sequence or -1 if it does not exist
+ */
+ public static int findSequence(byte[] haystack, byte[] needle) {
+ for (int i = 0; i < haystack.length - needle.length; i++) {
+ boolean match = true;
+ for (int j = 0; j < needle.length; j++) {
+ if (haystack[i + j] != needle[j]) {
+ match = false;
+ break;
+ }
+ }
+ if (match) {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ /**
+ * Returns the raw salt from the provided "full salt" which could be KDF-specific.
+ *
+ * Examples:
+ *
+ * Argon2 -> {@code $argon2id$v=19$m=4096,t=3,p=1$abcdefABCDEF0123456789}
+ * Bcrypt -> {@code $2a$10$abcdefABCDEF0123456789}
+ * Scrypt -> {@code $s0$e0801$abcdefABCDEF0123456789}
+ *
+ * If the KDF does not have a custom encoding for the salt, the provided "full salt" is returned intact.
+ *
+ * @param fullSalt the KDF-formatted salt
+ * @param kdf the KDF used
+ * @return the raw salt
+ */
+ public static byte[] extractRawSalt(byte[] fullSalt, KeyDerivationFunction kdf) {
+ final String saltString = new String(fullSalt, StandardCharsets.UTF_8);
+ switch (kdf) {
+ case ARGON2:
+ return Argon2CipherProvider.isArgon2FormattedSalt(saltString) ? Argon2CipherProvider.extractRawSaltFromArgon2Salt(saltString) : fullSalt;
+ case BCRYPT:
+ return BcryptCipherProvider.isBcryptFormattedSalt(saltString) ? BcryptCipherProvider.extractRawSalt(saltString) : fullSalt;
+ case SCRYPT:
+ return ScryptCipherProvider.isScryptFormattedSalt(saltString) ? ScryptCipherProvider.extractRawSaltFromScryptSalt(saltString) : fullSalt;
+ // case PBKDF2:
+ // case NONE:
+ // case NIFI_LEGACY:
+ // case OPENSSL_EVP_BYTES_TO_KEY:
+ default:
+ return fullSalt;
+ }
+ }
}
\ No newline at end of file
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/PBKDF2CipherProvider.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/PBKDF2CipherProvider.java
index fbad3b6..969acc7 100644
--- a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/PBKDF2CipherProvider.java
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/PBKDF2CipherProvider.java
@@ -16,11 +16,6 @@
*/
package org.apache.nifi.security.util.crypto;
-import java.nio.charset.StandardCharsets;
-import java.security.SecureRandom;
-import javax.crypto.Cipher;
-import javax.crypto.SecretKey;
-import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.processor.exception.ProcessException;
import org.apache.nifi.security.util.EncryptionMethod;
@@ -30,11 +25,15 @@ import org.bouncycastle.crypto.digests.SHA1Digest;
import org.bouncycastle.crypto.digests.SHA256Digest;
import org.bouncycastle.crypto.digests.SHA384Digest;
import org.bouncycastle.crypto.digests.SHA512Digest;
-import org.bouncycastle.crypto.generators.PKCS5S2ParametersGenerator;
-import org.bouncycastle.crypto.params.KeyParameter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+
public class PBKDF2CipherProvider extends RandomIVPBECipherProvider {
private static final Logger logger = LoggerFactory.getLogger(PBKDF2CipherProvider.class);
private static final int DEFAULT_SALT_LENGTH = 16;
@@ -142,10 +141,10 @@ public class PBKDF2CipherProvider extends RandomIVPBECipherProvider {
throw new IllegalArgumentException("The salt must be at least " + DEFAULT_SALT_LENGTH + " bytes. To generate a salt, use PBKDF2CipherProvider#generateSalt()");
}
- PKCS5S2ParametersGenerator gen = new PKCS5S2ParametersGenerator(this.prf);
- gen.init(password.getBytes(StandardCharsets.UTF_8), salt, getIterationCount());
- byte[] dk = ((KeyParameter) gen.generateDerivedParameters(keyLength)).getKey();
- SecretKey tempKey = new SecretKeySpec(dk, algorithm);
+ // Hasher expects keyLength in bytes; instance field is in bits
+ PBKDF2SecureHasher pbkdf2SecureHasher = new PBKDF2SecureHasher(getPRFName(), getIterationCount(), salt.length, keyLength / 8);
+ byte[] hashBytes = pbkdf2SecureHasher.hashRaw(password.getBytes(StandardCharsets.UTF_8), salt);
+ SecretKey tempKey = new SecretKeySpec(hashBytes, algorithm);
KeyedCipherProvider keyedCipherProvider = new AESKeyedCipherProvider();
return keyedCipherProvider.getCipher(encryptionMethod, tempKey, iv, encryptMode);
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/PBKDF2SecureHasher.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/PBKDF2SecureHasher.java
new file mode 100644
index 0000000..9e7b4c7
--- /dev/null
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/PBKDF2SecureHasher.java
@@ -0,0 +1,287 @@
+/*
+ * 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.lang3.StringUtils;
+import org.bouncycastle.crypto.Digest;
+import org.bouncycastle.crypto.digests.MD5Digest;
+import org.bouncycastle.crypto.digests.SHA1Digest;
+import org.bouncycastle.crypto.digests.SHA256Digest;
+import org.bouncycastle.crypto.digests.SHA384Digest;
+import org.bouncycastle.crypto.digests.SHA512Digest;
+import org.bouncycastle.crypto.generators.PKCS5S2ParametersGenerator;
+import org.bouncycastle.crypto.params.KeyParameter;
+import org.bouncycastle.util.encoders.Hex;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Provides an implementation of {@code PBKDF2} for secure password hashing.
+ * <p>
+ * One <strong>critical</strong> difference is that this implementation uses a
+ * <strong>static universal</strong> salt unless instructed otherwise, which provides
+ * strict determinism across nodes in a cluster. The purpose for this is to allow for
+ * blind equality comparison of sensitive values hashed on different nodes (with
+ * potentially different {@code nifi.sensitive.props.key} values) during flow inheritance
+ * (see {@code FingerprintFactory}).
+ * <p>
+ * The resulting output is referred to as a <em>hash</em> to be consistent with {@link SecureHasher} terminology.
+ */
+public class PBKDF2SecureHasher extends AbstractSecureHasher {
+ private static final Logger logger = LoggerFactory.getLogger(PBKDF2SecureHasher.class);
+
+ private static final String DEFAULT_PRF = "SHA-512";
+ private static final int DEFAULT_SALT_LENGTH = 16;
+ /**
+ * This can be calculated automatically using the code {@see PBKDF2CipherProviderGroovyTest#calculateMinimumIterationCount} or manually updated by a maintainer
+ */
+ private static final int DEFAULT_ITERATION_COUNT = 160_000;
+
+ // Different sources list this in bits and bytes, but RFC 8018 uses bytes (octets [8-bit sequences] to be precise)
+ private static final int DEFAULT_DK_LENGTH = 32;
+
+ private static final int MIN_ITERATION_COUNT = 1;
+ private static final int MIN_DK_LENGTH = 1;
+ private static final int MIN_SALT_LENGTH = 8;
+
+ private final Digest prf;
+ private final Integer iterationCount;
+ private final int dkLength;
+
+ /**
+ * Instantiates a PBKDF2 secure hasher with the default number of iterations and the default PRF. Currently 160,000 iterations and SHA-512.
+ */
+ public PBKDF2SecureHasher() {
+ this(DEFAULT_PRF, DEFAULT_ITERATION_COUNT, 0, DEFAULT_DK_LENGTH);
+ }
+
+ /**
+ * Instantiates a PBKDF2 secure hasher with the provided number of iterations and derived key (output) length in bytes, using the default PRF ({@code SHA512}).
+ *
+ * @param iterationCount the number of iterations
+ * @param dkLength the desired output length in bytes
+ */
+ public PBKDF2SecureHasher(int iterationCount, int dkLength) {
+ this(DEFAULT_PRF, iterationCount, 0, dkLength);
+ }
+
+ /**
+ * Instantiates a PBKDF2 secure hasher using the provided cost parameters. A unique
+ * salt of the specified length will be generated on every hash request.
+ * Currently supported PRFs are {@code MD5} (deprecated), {@code SHA1} (deprecated), {@code SHA256},
+ * {@code SHA384}, and {@code SHA512}. Unknown PRFs will default to {@code SHA512}.
+ *
+ * @param prf a String representation of the PRF name, e.g. "SHA256", "SHA-384" "sha_512"
+ * @param iterationCount the number of iterations
+ * @param saltLength the salt length in bytes ({@code >= 8}, {@code 0} indicates a static salt)
+ * @param dkLength the output length in bytes ({@code 1 to (2^32 - 1) * hLen})
+ */
+ public PBKDF2SecureHasher(String prf, Integer iterationCount, int saltLength, int dkLength) {
+ validateParameters(prf, iterationCount, saltLength, dkLength);
+ this.prf = resolvePRF(prf);
+ this.iterationCount = iterationCount;
+ this.saltLength = saltLength;
+ this.dkLength = dkLength;
+ }
+
+ /**
+ * Enforces valid PBKDF2 secure hasher cost parameters are provided.
+ *
+ * @param iterationCount the (log) number of key expansion rounds
+ * @param saltLength the salt length in bytes {@code >= 8})
+ * @param dkLength the output length in bytes ({@code 1 to (2^32 - 1) * hLen})
+ */
+ private void validateParameters(String prf, Integer iterationCount, int saltLength, int dkLength) {
+ logger.debug("Validating PBKDF2 secure hasher with prf {}, iteration count {}, salt length {} bytes, output length {} bytes", prf, iterationCount, saltLength, dkLength);
+
+ if (!isIterationCountValid(iterationCount)) {
+ logger.error("The provided iteration count {} is below the minimum {}.", iterationCount, MIN_ITERATION_COUNT);
+ throw new IllegalArgumentException("Invalid iterationCount is not within iteration count boundary.");
+ }
+ initializeSalt(saltLength);
+
+ // Calculate hLen based on PRF
+ Digest prfType = resolvePRF(prf);
+ int hLen = prfType.getDigestSize();
+ logger.debug("The PRF is {}, with a digest size (hLen) of {} bytes", prfType.getAlgorithmName(), hLen);
+
+ if (!isDKLengthValid(hLen, dkLength)) {
+ logger.error("The provided dkLength {} bytes is outside the output boundary {} to {}.", dkLength, MIN_DK_LENGTH, getMaxDKLength(hLen));
+ throw new IllegalArgumentException("Invalid dkLength is not within derived key length boundary.");
+ }
+ }
+
+ /**
+ * Returns the algorithm-specific name for logging and messages.
+ *
+ * @return the algorithm name
+ */
+ @Override
+ String getAlgorithmName() {
+ return "PBKDF2";
+ }
+
+ /**
+ * Returns {@code true} if the algorithm can accept empty (non-{@code null}) inputs.
+ *
+ * @return the true if {@code ""} is allowable input
+ */
+ @Override
+ boolean acceptsEmptyInput() {
+ return true;
+ }
+
+ /**
+ * Returns true if the provided cost factor is within boundaries. The lower bound >= 1.
+ *
+ * @param iterationCount the (log) number of key expansion rounds
+ * @return true if cost factor is within boundaries
+ */
+ public static boolean isIterationCountValid(Integer iterationCount) {
+ if (iterationCount < DEFAULT_ITERATION_COUNT) {
+ logger.warn("The provided iteration count {} is below the recommended minimum {}.", iterationCount, DEFAULT_ITERATION_COUNT);
+ }
+ // By definition, all ints are <= Integer.MAX_VALUE
+ return iterationCount >= MIN_ITERATION_COUNT;
+ }
+
+ /**
+ * Returns the algorithm-specific default salt length in bytes.
+ *
+ * @return the default salt length
+ */
+ @Override
+ int getDefaultSaltLength() {
+ return DEFAULT_SALT_LENGTH;
+ }
+
+ /**
+ * Returns the algorithm-specific minimum salt length in bytes.
+ *
+ * @return the min salt length
+ */
+ @Override
+ int getMinSaltLength() {
+ return MIN_SALT_LENGTH;
+ }
+
+ /**
+ * Returns the algorithm-specific maximum salt length in bytes.
+ *
+ * @return the max salt length
+ */
+ @Override
+ int getMaxSaltLength() {
+ return Integer.MAX_VALUE;
+ }
+
+ /**
+ * Returns whether the provided hash (derived key) length is within boundaries given the configured PRF. The lower bound >= 1 and the
+ * upper bound <= ((2^32 - 1) * 32) * hLen.
+ *
+ * @param hLen the PRF digest size in bytes
+ * @param dkLength the output length in bytes
+ * @return true if dkLength is within boundaries
+ */
+ public static boolean isDKLengthValid(int hLen, Integer dkLength) {
+ final int MAX_DK_LENGTH = getMaxDKLength(hLen);
+ logger.debug("The max dkLength is {} bytes for hLen {} bytes.", MAX_DK_LENGTH, hLen);
+
+ return dkLength >= MIN_DK_LENGTH && dkLength <= MAX_DK_LENGTH;
+ }
+
+ /**
+ * Returns the maximum length of the derived key in bytes given the digest length in bytes of the underlying PRF.
+ * If the calculated maximum exceeds {@link Integer#MAX_VALUE}, that is returned instead, as RFC 8018 specifies
+ * {@code keyLength INTEGER (1..MAX) OPTIONAL}.
+ *
+ * @param hLen the length of the PRF digest output in bytes
+ * @return the maximum possible length of the derived key in bytes
+ */
+ private static int getMaxDKLength(int hLen) {
+ final long MAX_LENGTH = ((Double.valueOf((Math.pow(2, 32)))).longValue() - 1) * hLen;
+ return Long.valueOf(Math.min(MAX_LENGTH, Integer.MAX_VALUE)).intValue();
+ }
+
+ /**
+ * Internal method to hash the raw bytes.
+ *
+ * @param input the raw bytes to hash (can be length 0)
+ * @return the generated hash
+ */
+ byte[] hash(byte[] input) {
+ // Contains only the raw salt
+ byte[] rawSalt = getSalt();
+
+ return hash(input, rawSalt);
+ }
+
+ /**
+ * Internal method to hash the raw bytes.
+ *
+ * @param input the raw bytes to hash (can be length 0)
+ * @param rawSalt the raw bytes to salt
+ * @return the generated hash
+ */
+ byte[] hash(byte[] input, byte[] rawSalt) {
+ logger.debug("Creating PBKDF2 hash with salt [{}] ({} bytes)", Hex.toHexString(rawSalt), rawSalt.length);
+
+ if (!isSaltLengthValid(rawSalt.length)) {
+ throw new IllegalArgumentException("The salt length (" + rawSalt.length + " bytes) is invalid");
+ }
+
+ final long startNanos = System.nanoTime();
+ PKCS5S2ParametersGenerator gen = new PKCS5S2ParametersGenerator(this.prf);
+ gen.init(input, rawSalt, iterationCount);
+ // The generateDerivedParameters method expects the dkLength in bits
+ byte[] hash = ((KeyParameter) gen.generateDerivedParameters(dkLength * 8)).getKey();
+ final long generateNanos = System.nanoTime();
+
+ final long totalDurationMillis = TimeUnit.NANOSECONDS.toMillis(generateNanos - startNanos);
+
+ logger.debug("Generated PBKDF2 hash in {} ms", totalDurationMillis);
+
+ return hash;
+ }
+
+ private Digest resolvePRF(final String prf) {
+ if (StringUtils.isEmpty(prf)) {
+ throw new IllegalArgumentException("Cannot resolve empty PRF");
+ }
+ String formattedPRF = prf.toLowerCase().replaceAll("[\\W]+", "");
+ logger.debug("Resolved PRF {} to {}", prf, formattedPRF);
+ switch (formattedPRF) {
+ case "md5":
+ logger.warn("MD5 is a deprecated cryptographic hash function and should not be used");
+ return new MD5Digest();
+ case "sha1":
+ logger.warn("SHA-1 is a deprecated cryptographic hash function and should not be used");
+ return new SHA1Digest();
+ case "sha256":
+ return new SHA256Digest();
+ case "sha384":
+ return new SHA384Digest();
+ case "sha512":
+ return new SHA512Digest();
+ default:
+ logger.warn("Could not resolve PRF {}. Using default PRF {} instead", prf, DEFAULT_PRF);
+ return new SHA512Digest();
+ }
+ }
+}
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/RandomIVPBECipherProvider.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/RandomIVPBECipherProvider.java
index 66fd1ba..99ad9c6 100644
--- a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/RandomIVPBECipherProvider.java
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/RandomIVPBECipherProvider.java
@@ -26,9 +26,9 @@ import org.apache.nifi.security.util.EncryptionMethod;
import org.slf4j.Logger;
public abstract class RandomIVPBECipherProvider implements PBECipherProvider {
- static final byte[] SALT_DELIMITER = "NiFiSALT".getBytes(StandardCharsets.UTF_8);
+ public static final byte[] SALT_DELIMITER = "NiFiSALT".getBytes(StandardCharsets.UTF_8);
static final int MAX_SALT_LIMIT = 128;
- static final byte[] IV_DELIMITER = "NiFiIV".getBytes(StandardCharsets.UTF_8);
+ public static final byte[] IV_DELIMITER = "NiFiIV".getBytes(StandardCharsets.UTF_8);
// This is 16 bytes for AES but can vary for other ciphers
static final int MAX_IV_LIMIT = 16;
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/ScryptCipherProvider.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/ScryptCipherProvider.java
index 7bc1882..537ac1a 100644
--- a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/ScryptCipherProvider.java
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/ScryptCipherProvider.java
@@ -49,7 +49,7 @@ public class ScryptCipherProvider extends RandomIVPBECipherProvider {
private static final int DEFAULT_R = 8;
private static final int DEFAULT_P = 1;
- private static final Pattern SCRYPT_SALT_FORMAT = Pattern.compile("^\\$s0\\$[a-f0-9]{5,16}\\$[\\w\\/\\.]{12,44}");
+ private static final Pattern SCRYPT_SALT_FORMAT = Pattern.compile("^\\$s0\\$[a-f0-9]{5,16}\\$[\\w\\/\\+]{12,44}");
private static final Pattern MCRYPT_SALT_FORMAT = Pattern.compile("^\\$\\d+\\$\\d+\\$\\d+\\$[a-f0-9]{16,64}");
/**
@@ -88,12 +88,13 @@ public class ScryptCipherProvider extends RandomIVPBECipherProvider {
/**
* Returns whether the provided parallelization factor (p value) is within boundaries. The lower bound > 0 and the
* upper bound is calculated based on the provided block size (r value).
+ *
* @param r the block size in bytes
* @param p the parallelization factor
* @return true if p is within boundaries
*/
public static boolean isPValid(int r, int p) {
- if (!isRValid(r)){
+ if (!isRValid(r)) {
logger.warn("The provided block size {} must be greater than 0", r);
throw new IllegalArgumentException("Invalid r value; must be greater than 0");
}
@@ -104,6 +105,7 @@ public class ScryptCipherProvider extends RandomIVPBECipherProvider {
/**
* Returns whether the provided block size (r value) is a positive integer or not.
+ *
* @param r the block size in bytes
* @return true if r is a positive integer
*/
@@ -178,25 +180,69 @@ public class ScryptCipherProvider extends RandomIVPBECipherProvider {
throw new IllegalArgumentException(String.valueOf(keyLength) + " is not a valid key length for " + cipherName);
}
- String scryptSalt = formatSaltForScrypt(salt);
- List<Integer> params = new ArrayList<>(3);
- byte[] rawSalt = new byte[Scrypt.getDefaultSaltLength()];
+ /** 1. Parse the incoming salt $s0$20101$ABCDEF... into rawSalt, N, r, p
+ * 2. Generate the hash (derived key) from Scrypt using password, rawSalt, N, r, p, dkLen
+ * 3. Pass derived key into cipher
+ */
+ String saltString = new String(salt, StandardCharsets.UTF_8);
+ byte[] rawSalt = new byte[getDefaultSaltLength()];
+ int n, r, p;
+ if (isScryptFormattedSalt(saltString)) {
+ List<Integer> params = new ArrayList<>(3);
+ parseSalt(saltString, rawSalt, params);
+ n = params.get(0);
+ r = params.get(1);
+ p = params.get(2);
+ } else {
+ rawSalt = salt;
+ n = getN();
+ r = getR();
+ p = getP();
+ }
- parseSalt(scryptSalt, rawSalt, params);
+ ScryptSecureHasher scryptSecureHasher = new ScryptSecureHasher(n, r, p, keyLength / 8);
+ try {
+ byte[] keyBytes = scryptSecureHasher.hashRaw(password.getBytes(StandardCharsets.UTF_8), rawSalt);
+ SecretKey tempKey = new SecretKeySpec(keyBytes, algorithm);
- String hash = Scrypt.scrypt(password, rawSalt, params.get(0), params.get(1), params.get(2), keyLength);
+ KeyedCipherProvider keyedCipherProvider = new AESKeyedCipherProvider();
+ return keyedCipherProvider.getCipher(encryptionMethod, tempKey, iv, encryptMode);
+ } catch (IllegalArgumentException e) {
+ if (e.getMessage().contains("The salt length")) {
+ throw new IllegalArgumentException("The raw salt must be greater than or equal to 8 bytes", e);
+ } else {
+ logger.error("Encountered an error generating the Scrypt hash", e);
+ throw e;
+ }
+ }
+ }
- // Split out the derived key from the hash and form a key object
- final String[] hashComponents = hash.split("\\$");
- final int HASH_INDEX = 4;
- if (hashComponents.length < HASH_INDEX) {
- throw new ProcessException("There was an error generating a scrypt hash -- the resulting hash was not properly formatted");
+ /**
+ * Returns the raw salt contained in the provided Scrypt salt string.
+ *
+ * @param scryptSalt the full Scrypt salt
+ * @return the raw salt decoded from Base64
+ */
+ public static byte[] extractRawSaltFromScryptSalt(String scryptSalt) {
+ final String[] saltComponents = scryptSalt.split("\\$");
+ if (saltComponents.length < 4) {
+ throw new IllegalArgumentException("Could not parse salt");
}
- byte[] keyBytes = Base64.decodeBase64(hashComponents[HASH_INDEX]);
- SecretKey tempKey = new SecretKeySpec(keyBytes, algorithm);
+ return Base64.decodeBase64(saltComponents[3]);
+ }
- KeyedCipherProvider keyedCipherProvider = new AESKeyedCipherProvider();
- return keyedCipherProvider.getCipher(encryptionMethod, tempKey, iv, encryptMode);
+ /**
+ * Returns {@code true} if the salt string is a valid Scrypt salt string ({@code $s0$e0801$abcdefghi..{22}}).
+ *
+ * @param salt the salt string to evaluate
+ * @return true if valid Scrypt salt
+ */
+ public static boolean isScryptFormattedSalt(String salt) {
+ if (salt == null || salt.length() == 0) {
+ throw new IllegalArgumentException("The salt cannot be empty. To generate a salt, use ScryptCipherProvider#generateSalt()");
+ }
+ Matcher matcher = SCRYPT_SALT_FORMAT.matcher(salt);
+ return matcher.find();
}
private void parseSalt(String scryptSalt, byte[] rawSalt, List<Integer> params) {
@@ -206,11 +252,7 @@ public class ScryptCipherProvider extends RandomIVPBECipherProvider {
/** Salt format is $s0$params$saltB64 where params is encoded according to
* {@link Scrypt#parseParameters(String)}*/
- final String[] saltComponents = scryptSalt.split("\\$");
- if (saltComponents.length < 4) {
- throw new IllegalArgumentException("Could not parse salt");
- }
- byte[] salt = Base64.decodeBase64(saltComponents[3]);
+ byte[] salt = extractRawSaltFromScryptSalt(scryptSalt);
if (rawSalt.length < salt.length) {
byte[] tempBytes = new byte[salt.length];
System.arraycopy(rawSalt, 0, tempBytes, 0, rawSalt.length);
@@ -221,30 +263,52 @@ public class ScryptCipherProvider extends RandomIVPBECipherProvider {
if (params == null) {
params = new ArrayList<>(3);
}
+ final String[] saltComponents = scryptSalt.split("\\$");
params.addAll(Scrypt.parseParameters(saltComponents[2]));
}
/**
- * Formats the salt into a string which Scrypt can understand containing the N, r, p values along with the salt value. If the provided salt contains all values, the response will be unchanged.
- * If it only contains the raw salt value, the resulting return value will also include the current instance version, N, r, and p.
+ * Formats the salt into a string which Scrypt can understand containing the N, r, p values along with the salt
+ * value. If the provided salt contains all values, the response will be unchanged.
+ * If it only contains the raw salt value, the resulting return value will also include the current instance
+ * version, and provided N, r, and p.
+ * <p>
+ * The salt is expected to be in the format {@code new String(saltBytes, StandardCharsets.UTF_8) => "$s0$e0801$ABCDEF...."}.
*
* @param salt the provided salt
* @return the properly-formatted and complete salt
*/
- private String formatSaltForScrypt(byte[] salt) {
- if (salt == null || salt.length == 0) {
- throw new IllegalArgumentException("The salt cannot be empty. To generate a salt, use ScryptCipherProvider#generateSalt()");
+ public String formatSaltForScrypt(byte[] salt) {
+ String saltString = new String(salt, StandardCharsets.UTF_8);
+ if (isScryptFormattedSalt(saltString)) {
+ return saltString;
+ } else {
+ // The provided salt is not complete, so get the current instance cost parameters
+ return formatSaltForScrypt(salt, getN(), getR(), getP());
}
+ }
+ /**
+ * Formats the salt into a string which Scrypt can understand containing the N, r, p values along with the salt
+ * value. If the provided salt contains all values, the response will be unchanged.
+ * If it only contains the raw salt value, the resulting return value will also include the provided N, r, and p.
+ * <p>
+ * The salt is expected to be in the format {@code new String(saltBytes, StandardCharsets.UTF_8) => "$s0$e0801$ABCDEF...."}.
+ *
+ * @param salt the provided salt
+ * @param n the N param
+ * @param r the r param
+ * @param p the p param
+ * @return the properly-formatted and complete salt
+ */
+ public static String formatSaltForScrypt(byte[] salt, int n, int r, int p) {
String saltString = new String(salt, StandardCharsets.UTF_8);
- Matcher matcher = SCRYPT_SALT_FORMAT.matcher(saltString);
-
- if (matcher.find()) {
+ if (isScryptFormattedSalt(saltString)) {
return saltString;
} else {
if (saltString.startsWith("$")) {
logger.warn("Salt starts with $ but is not valid scrypt salt");
- matcher = MCRYPT_SALT_FORMAT.matcher(saltString);
+ Matcher matcher = MCRYPT_SALT_FORMAT.matcher(saltString);
if (matcher.find()) {
logger.warn("The salt appears to be of the modified mcrypt format. Use ScryptCipherProvider#translateSalt(mcryptSalt) to form a valid salt");
return translateSalt(saltString);
@@ -269,7 +333,7 @@ public class ScryptCipherProvider extends RandomIVPBECipherProvider {
* @param mcryptSalt the mcrypt-formatted salt string
* @return the formatted salt to use with Java Scrypt
*/
- public String translateSalt(String mcryptSalt) {
+ public static String translateSalt(String mcryptSalt) {
if (StringUtils.isEmpty(mcryptSalt)) {
throw new IllegalArgumentException("Cannot translate empty salt");
}
@@ -282,7 +346,7 @@ public class ScryptCipherProvider extends RandomIVPBECipherProvider {
String[] components = mcryptSalt.split("\\$");
try {
- return Scrypt.formatSalt(Hex.decodeHex(components[4].toCharArray()), Integer.valueOf(components[1]), Integer.valueOf(components[2]), Integer.valueOf(components[3]));
+ return Scrypt.formatSalt(Hex.decodeHex(components[4].toCharArray()), Integer.parseInt(components[1]), Integer.parseInt(components[2]), Integer.parseInt(components[3]));
} catch (DecoderException e) {
final String msg = "Mcrypt salt was not properly hex-encoded";
logger.warn(msg);
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/ScryptSecureHasher.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/ScryptSecureHasher.java
new file mode 100644
index 0000000..a761ed7
--- /dev/null
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/ScryptSecureHasher.java
@@ -0,0 +1,265 @@
+/*
+ * 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.nifi.security.util.crypto.scrypt.Scrypt;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.math.BigInteger;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Provides an implementation of {@code Scrypt} for secure password hashing.
+ * <p>
+ * One <strong>critical</strong> difference is that this implementation uses a
+ * <strong>static universal</strong> salt unless instructed otherwise, which provides
+ * strict determinism across nodes in a cluster. The purpose for this is to allow for
+ * blind equality comparison of sensitive values hashed on different nodes (with
+ * potentially different {@code nifi.sensitive.props.key} values) during flow inheritance
+ * (see {@code FingerprintFactory}).
+ * <p>
+ * The resulting output is referred to as a <em>hash</em> to be consistent with {@link SecureHasher} terminology,
+ * but the length parameter is clarified as the <em>derived key length</em> {@code dkLen} in Scrypt terms, not to be
+ * confused with the internal concept of <em>hash length</em> for the PBKDF2 cryptographic hash function (CHF) primitive (SHA-256).
+ */
+public class ScryptSecureHasher extends AbstractSecureHasher {
+ private static final Logger logger = LoggerFactory.getLogger(ScryptSecureHasher.class);
+
+ /**
+ * These values can be calculated automatically using the code {@see ScryptCipherProviderGroovyTest#calculateMinimumParameters} or manually updated by a maintainer
+ */
+ private static final int DEFAULT_N = Double.valueOf(Math.pow(2, 14)).intValue();
+ private static final int DEFAULT_R = 8;
+ private static final int DEFAULT_P = 1;
+ private static final int DEFAULT_DK_LENGTH = 32;
+ private static final int DEFAULT_SALT_LENGTH = Scrypt.getDefaultSaltLength();
+
+ private static final int MIN_P = 1;
+ private static final int MIN_DK_LENGTH = 1;
+ private static final int MIN_N = 1;
+ private static final int MIN_R = 1;
+ private static final int MAX_R = Double.valueOf(Math.pow(2, 31)).intValue() - 1;
+ private static final int MIN_SALT_LENGTH = 8;
+ private static final int MAX_SALT_LENGTH = Double.valueOf(Math.pow(2, 31)).intValue() - 1;
+
+ private final int n;
+ private final int r;
+ private final int p;
+ private final int dkLength;
+
+ /**
+ * Instantiates an Scrypt secure hasher using the default cost parameters
+ * ({@code N = }{@link #DEFAULT_N},
+ * {@code r = }{@link #DEFAULT_R},
+ * {@code p = }{@link #DEFAULT_R},
+ * {@code dkLen = }{@link #DEFAULT_DK_LENGTH}). A static salt is also used.
+ */
+ public ScryptSecureHasher() {
+ this(DEFAULT_N, DEFAULT_R, DEFAULT_P, DEFAULT_DK_LENGTH, 0);
+ }
+
+ /**
+ * Instantiates an Scrypt secure hasher using the provided cost parameters. A static
+ * {@link #DEFAULT_SALT_LENGTH} byte salt will be generated on every hash request.
+ *
+ * @param n number of iterations (power of 2 from {@code 1 to 2^(128 * r / 8)})
+ * @param r the block size of memory ({@code > 0})
+ * @param p parallelization factor from ({@code 1 to ((2^32-1) * 32) / (128 * r)})
+ * @param dkLength the output length in bytes ({@code 1 to (2^32 - 1) * 32})
+ */
+ public ScryptSecureHasher(int n, int r, int p, int dkLength) {
+ this(n, r, p, dkLength, 0);
+ }
+
+ /**
+ * Instantiates an Scrypt secure hasher using the provided cost parameters. A unique
+ * salt of the specified length will be generated on every hash request.
+ *
+ * @param n number of iterations (power of 2 from {@code 1 to 2^(128 * r / 8)})
+ * @param r the block size of memory ({@code > 0})
+ * @param p parallelization factor from ({@code 1 to ((2^32-1) * 32) / (128 * r)})
+ * @param dkLength the output length in bytes ({@code 1 to (2^32 - 1) * 32})
+ * @param saltLength the salt length in bytes {@code >= 8})
+ */
+ public ScryptSecureHasher(int n, int r, int p, int dkLength, int saltLength) {
+ validateParameters(n, r, p, dkLength, saltLength);
+ this.n = n;
+ this.r = r;
+ this.p = p;
+ this.dkLength = dkLength;
+ this.saltLength = saltLength;
+ }
+
+ /**
+ * Enforces valid Scrypt secure hasher cost parameters are provided.
+ *
+ * @param n number of iterations (power of 2 from {@code 1 to 2^(128 * r / 8)})
+ * @param r the block size of memory ({@code > 0})
+ * @param p parallelization factor from ({@code 1 to ((2^32-1) * 32) / (128 * r)})
+ * @param dkLength the output length in bytes ({@code 1 to (2^32 - 1) * 32})
+ * @param saltLength the salt length in bytes {@code >= 8})
+ */
+ private void validateParameters(Integer n, Integer r, int p, Integer dkLength, Integer saltLength) {
+ // Check r first because it is not dependent on other parameters
+ if (!isRValid(r)) {
+ logger.error("The provided block size r {} ( * 128 bytes) is outside the boundary of 1 to 2^31 - 1.", r);
+ throw new IllegalArgumentException("Invalid r is not within the memory boundary.");
+ }
+ if (!isNValid(n, r)) {
+ logger.error("The iteration count N {} is outside the boundary of powers of 2 from 1 to 2^(128 * r / 8).", n);
+ throw new IllegalArgumentException("Invalid N exceeds the iterations boundary.");
+ }
+ if (!isPValid(p, r)) {
+ logger.error("The provided parallelization factor {} is outside the boundary of 1 to ((2^32 - 1) * 32) / (128 * r).", p);
+ throw new IllegalArgumentException("Invalid p exceeds the parallelism boundary.");
+ }
+ if (!isDKLengthValid(dkLength)) {
+ logger.error("The provided hash length {} is outside the boundary of 1 to (2^32 - 1) * 32.", dkLength);
+ throw new IllegalArgumentException("Invalid hash length is not within the dkLength boundary.");
+ }
+
+ initializeSalt(saltLength);
+ }
+
+ /**
+ * Internal method to hash the raw bytes.
+ *
+ * @param input the raw bytes to hash (can be length 0)
+ * @return the generated hash
+ */
+ byte[] hash(byte[] input) {
+ // Contains only the raw salt
+ byte[] rawSalt = getSalt();
+
+ return hash(input, rawSalt);
+ }
+
+ /**
+ * Internal method to hash the raw bytes.
+ *
+ * @param input the raw bytes to hash (can be length 0)
+ * @param rawSalt the raw bytes to salt
+ * @return the generated hash
+ */
+ byte[] hash(byte[] input, byte[] rawSalt) {
+ logger.debug("Creating {} byte Scrypt hash with salt [{}]", dkLength, org.bouncycastle.util.encoders.Hex.toHexString(rawSalt));
+
+ if (!isSaltLengthValid(rawSalt.length)) {
+ throw new IllegalArgumentException("The salt length (" + rawSalt.length + " bytes) is invalid");
+ }
+
+ final long startNanos = System.nanoTime();
+ byte[] hash = Scrypt.scrypt(input, rawSalt, n, r, p, dkLength * 8);
+ final long generateNanos = System.nanoTime();
+
+ final long totalDurationMillis = TimeUnit.NANOSECONDS.toMillis(generateNanos - startNanos);
+
+ logger.debug("Generated Scrypt hash in {} ms", totalDurationMillis);
+
+ return hash;
+ }
+
+ /**
+ * Returns true if the provided iteration count N is within boundaries. The lower bound >= 1 and the
+ * upper bound <= 2^(128 * r / 8).
+ *
+ * @param n number of iterations
+ * @param r the blocksize parameter
+ * @return true if iterations is within boundaries
+ */
+ protected static boolean isNValid(Integer n, int r) {
+ if (n < DEFAULT_N) {
+ logger.warn("The provided iteration count N {} is below the recommended minimum {}.", n, DEFAULT_N);
+ }
+ return n >= MIN_N && n <= Double.valueOf(Math.pow(2, (128 * r / 8.0))).intValue();
+ }
+
+ /**
+ * Returns true if the provided block size in bytes is within boundaries. The lower bound >= 1 and the
+ * upper bound <= 2^32 - 1.
+ *
+ * @param r the integer number * 128 B used
+ * @return true if r is within boundaries
+ */
+ protected static boolean isRValid(int r) {
+ if (r < DEFAULT_R) {
+ logger.warn("The provided r size {} * 128 B is below the recommended minimum {}.", r, DEFAULT_R);
+ }
+ return r >= MIN_R && r <= MAX_R;
+ }
+
+ /**
+ * Returns true if the provided parallelization factor is within boundaries. The lower bound >= 1 and the
+ * upper bound <= ((2^32 - 1) * 32) / (128 * r).
+ *
+ * @param p degree of parallelism
+ * @param r the blocksize parameter
+ * @return true if parallelism is within boundaries
+ */
+ protected static boolean isPValid(int p, int r) {
+ if (p < DEFAULT_P) {
+ logger.warn("The provided parallelization factor {} is below the recommended minimum {}.", p, DEFAULT_P);
+ }
+ long dividend = Double.valueOf((Math.pow(2, 32) - 1) * 32).longValue();
+ int divisor = 128 * r;
+ BigInteger MAX_P = new BigInteger(String.valueOf(dividend)).divide(new BigInteger(String.valueOf(divisor)));
+ logger.debug("Calculated maximum p value as (2^32 - 1) * 32 [{}] / (128 * r) [{}] = {}", dividend, divisor, MAX_P.intValue());
+ return p >= MIN_P && p <= MAX_P.intValue();
+ }
+
+ /**
+ * Returns whether the provided hash (derived key) length is within boundaries. The lower bound >= 1 and the
+ * upper bound <= (2^32 - 1) * 32.
+ *
+ * @param dkLength the output length in bytes
+ * @return true if dkLength is within boundaries
+ */
+ protected static boolean isDKLengthValid(Integer dkLength) {
+ return dkLength >= MIN_DK_LENGTH && dkLength <= UPPER_BOUNDARY;
+ }
+
+ /**
+ * Returns the algorithm-specific default salt length in bytes.
+ *
+ * @return the Scrypt default salt length
+ */
+ @Override
+ public int getDefaultSaltLength() {
+ return DEFAULT_SALT_LENGTH;
+ }
+
+ @Override
+ public int getMinSaltLength() {
+ return MIN_SALT_LENGTH;
+ }
+
+ @Override
+ public int getMaxSaltLength() {
+ return MAX_SALT_LENGTH;
+ }
+
+ @Override
+ String getAlgorithmName() {
+ return "Scrypt";
+ }
+
+ @Override
+ boolean acceptsEmptyInput() {
+ return false;
+ }
+}
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/SecureHasher.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/SecureHasher.java
index be0328f..bb1601f 100644
--- a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/SecureHasher.java
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/SecureHasher.java
@@ -39,6 +39,16 @@ public interface SecureHasher {
String hashHex(String input);
/**
+ * Returns a String representation of {@code CHF(input)} in hex-encoded format.
+ *
+ * @param input the input
+ * @param salt the provided salt
+ *
+ * @return the hex-encoded hash
+ */
+ String hashHex(String input, String salt);
+
+ /**
* Returns a String representation of {@code CHF(input)} in Base 64-encoded format.
*
* @param input the input
@@ -47,10 +57,30 @@ public interface SecureHasher {
String hashBase64(String input);
/**
+ * Returns a String representation of {@code CHF(input)} in Base 64-encoded format.
+ *
+ * @param input the input
+ * @param salt the provided salt
+ *
+ * @return the Base 64-encoded hash
+ */
+ String hashBase64(String input, String salt);
+
+ /**
* Returns a byte[] representation of {@code CHF(input)}.
*
* @param input the input
* @return the hash
*/
byte[] hashRaw(byte[] input);
+
+ /**
+ * Returns a byte[] representation of {@code CHF(input)}.
+ *
+ * @param input the input
+ * @param salt the provided salt
+ *
+ * @return the hash
+ */
+ byte[] hashRaw(byte[] input, byte[] salt);
}
diff --git a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/scrypt/Scrypt.java b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/scrypt/Scrypt.java
index b5622f8..60777ff 100644
--- a/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/scrypt/Scrypt.java
+++ b/nifi-commons/nifi-security-utils/src/main/java/org/apache/nifi/security/util/crypto/scrypt/Scrypt.java
@@ -110,6 +110,27 @@ public class Scrypt {
}
}
+ /**
+ * Hash the supplied input and generate raw unencoded output.
+ *
+ * @param input input in bytes
+ * @param salt the raw salt (16 bytes)
+ * @param n CPU cost parameter
+ * @param r memory cost parameter
+ * @param p parallelization parameter
+ * @param dkLen the desired key length in bits
+ * @return the hashed password
+ */
+ public static byte[] scrypt(byte[] input, byte[] salt, int n, int r, int p, int dkLen) {
+ try {
+ byte[] derived = deriveScryptKey(input, salt, n, r, p, dkLen);
+
+ return derived;
+ } catch (GeneralSecurityException e) {
+ throw new IllegalStateException("JVM doesn't support SHA1PRNG or HMAC_SHA256?");
+ }
+ }
+
public static String formatSalt(byte[] salt, int n, int r, int p) {
String params = encodeParams(n, r, p);
diff --git a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/BcryptCipherProviderGroovyTest.groovy b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/Argon2CipherProviderGroovyTest.groovy
similarity index 57%
copy from nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/BcryptCipherProviderGroovyTest.groovy
copy to nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/Argon2CipherProviderGroovyTest.groovy
index 5aa5aec..dde7fcb 100644
--- a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/BcryptCipherProviderGroovyTest.groovy
+++ b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/Argon2CipherProviderGroovyTest.groovy
@@ -16,13 +16,15 @@
*/
package org.apache.nifi.security.util.crypto
-import at.favre.lib.crypto.bcrypt.BCrypt
-import at.favre.lib.crypto.bcrypt.Radix64Encoder
import org.apache.commons.codec.binary.Base64
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.After
+import org.junit.Assume
+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
@@ -35,18 +37,20 @@ import java.nio.charset.StandardCharsets
import java.security.Security
import static groovy.test.GroovyAssert.shouldFail
-import static org.junit.Assert.assertTrue
@RunWith(JUnit4.class)
-class BcryptCipherProviderGroovyTest {
- private static final Logger logger = LoggerFactory.getLogger(BcryptCipherProviderGroovyTest.class)
+class Argon2CipherProviderGroovyTest extends GroovyTestCase {
+ private static final Logger logger = LoggerFactory.getLogger(Argon2CipherProviderGroovyTest.class)
private static List<EncryptionMethod> strongKDFEncryptionMethods
private static final int DEFAULT_KEY_LENGTH = 128
- public static final String MICROBENCHMARK = "microbenchmark"
+ private final String SALT_HEX = "0123456789ABCDEFFEDCBA9876543210"
private static ArrayList<Integer> AES_KEY_LENGTHS
+ RandomIVPBECipherProvider cipherProvider
+ private final IntRange FULL_SALT_LENGTH_RANGE= (49..53)
+
@BeforeClass
static void setUpOnce() throws Exception {
Security.addProvider(new BouncyCastleProvider())
@@ -66,6 +70,8 @@ class BcryptCipherProviderGroovyTest {
@Before
void setUp() throws Exception {
+ // Very fast parameters to test for correctness rather than production values
+ cipherProvider = new Argon2CipherProvider(1024, 1, 3)
}
@After
@@ -76,8 +82,6 @@ class BcryptCipherProviderGroovyTest {
@Test
void testGetCipherShouldBeInternallyConsistent() throws Exception {
// Arrange
- RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(4)
-
final String PASSWORD = "shortPassword"
final byte[] SALT = cipherProvider.generateSalt()
@@ -106,10 +110,102 @@ class BcryptCipherProviderGroovyTest {
}
@Test
- void testGetCipherWithExternalIVShouldBeInternallyConsistent() throws Exception {
+ void testArgon2ShouldSupportExternalCompatibility() throws Exception {
// Arrange
- RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(4)
+ // Default values are hashLength = 32, memory = 1024, parallelism = 1, iterations = 3, but the provided salt will contain the parameters used
+ cipherProvider = new Argon2CipherProvider()
+
+ final String PLAINTEXT = "This is a plaintext message."
+ final String PASSWORD = "thisIsABadPassword"
+ final int hashLength = 256
+
+ // These values can be generated by running `$ ./openssl_argon2.rb` in the terminal
+ final byte[] SALT = Hex.decodeHex("68d29a1d8021f45954333767358a2492" as char[])
+ logger.info("Expected salt: ${Hex.encodeHexString(SALT)}")
+ final byte[] IV = Hex.decodeHex("808590f35f9fba14dbda9c2bb2b76a79" as char[])
+
+ final String CIPHER_TEXT = "d672412857916880c79d573aa4f9d4971b85f07438d6f62f38a0e31314caa2e5"
+ logger.sanity("Ruby cipher text: ${CIPHER_TEXT}")
+ byte[] cipherBytes = Hex.decodeHex(CIPHER_TEXT as char[])
+ EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
+
+ // Sanity check
+ String rubyKeyHex = "8caf581795886d38f0c605e3d674f4961c658ee3625a8e8868be36c902d234ef"
+ 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")
+
+ // $argon2id$v=19$m=memory,t=iterations,p=parallelism$saltB64$hashB64
+ final String FULL_HASH = "\$argon2id\$v=19\$m=256,t=3,p=1\$aNKaHYAh9FlUMzdnNYokkg\$jK9YF5WIbTjwxgXj1nT0lhxljuNiWo6IaL42yQLSNO8"
+ logger.info("Full Hash: ${FULL_HASH}")
+
+ final String FULL_SALT = FULL_HASH[0..<FULL_HASH.lastIndexOf("\$")]
+ logger.info("Full salt: ${FULL_SALT}")
+
+ final String[] hashComponents = FULL_HASH.split("\\\$")
+ logger.info("hashComponents: ${Arrays.toString(hashComponents)}")
+ Map<String, String> saltParams = hashComponents[3].split(",").collectEntries { String pair ->
+ pair.split("=")
+ }
+ logger.info("saltParams: ${saltParams}")
+ def saltB64 = hashComponents[4]
+ byte[] salt = Base64.decodeBase64(saltB64)
+ logger.info("Salt: ${Hex.encodeHexString(salt)}")
+ assert salt == SALT
+
+ logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()}")
+ logger.info("External cipher text: ${CIPHER_TEXT} ${cipherBytes.length}")
+
+ // Act
+ Cipher cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, FULL_SALT.bytes, IV, hashLength, false)
+ byte[] recoveredBytes = cipher.doFinal(cipherBytes)
+ String recovered = new String(recoveredBytes, "UTF-8")
+ logger.info("Recovered: ${recovered}")
+
+ // Assert
+ assert PLAINTEXT.equals(recovered)
+ }
+
+ @Test
+ void testGetCipherShouldRejectInvalidIV() throws Exception {
+ // Arrange
+ 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
+ void testGetCipherWithExternalIVShouldBeInternallyConsistent() throws Exception {
+ // Arrange
final String PASSWORD = "shortPassword"
final byte[] SALT = cipherProvider.generateSalt()
final byte[] IV = Hex.decodeHex("01" * 16 as char[])
@@ -143,8 +239,6 @@ class BcryptCipherProviderGroovyTest {
Assume.assumeTrue("Test is being skipped due to this JVM lacking JCE Unlimited Strength Jurisdiction Policy file.",
CipherUtility.isUnlimitedStrengthCryptoSupported())
- RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(4)
-
final String PASSWORD = "shortPassword"
final byte[] SALT = cipherProvider.generateSalt()
@@ -175,187 +269,102 @@ class BcryptCipherProviderGroovyTest {
}
@Test
- void testHashPWShouldMatchTestVectors() {
- // Arrange
- final byte[] PASSWORD = 'abcdefghijklmnopqrstuvwxyz'.getBytes(StandardCharsets.UTF_8)
- final byte[] SALT = new Radix64Encoder.Default().decode('fVH8e28OQRj9tqiDXs1e1u'.getBytes(StandardCharsets.UTF_8))
- final String EXPECTED_HASH = '$2a$10$fVH8e28OQRj9tqiDXs1e1uxpsjN0c7II7YPKXua2NAKYvM6iQk7dq'
- final int WORK_FACTOR = 10
-
- // Act
- String calculatedHash = new String(BCrypt.withDefaults().hash(WORK_FACTOR, SALT, PASSWORD), StandardCharsets.UTF_8)
- logger.info("Generated ${calculatedHash}")
-
- // Assert
- assert calculatedHash == EXPECTED_HASH
- }
-
- @Test
- void testGetCipherShouldSupportExternalCompatibility() throws Exception {
+ void testGetCipherShouldNotAcceptInvalidSalts() throws Exception {
// Arrange
- final int WORK_FACTOR = 10
- RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(WORK_FACTOR)
-
- 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("f575109f118452c45d2ea121c58df136" as char[])
- final byte[] IV = Hex.decodeHex("57b017f9c0fdf8f0341424477e6baac1" as char[])
-
- // $v2$w2$base64_salt_22__base64_hash_31
- final String FULL_HASH = "\$2a\$10\$9XUQnxGEUsRdLqEhxY3xNujOQQkW3spKqxssi.Ox39VhhxB.z4496"
- 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 = "e51fee301593cc06b3112a7b9a16ab71be113c47d2b17cccc1b31a6e3b5a1631"
- byte[] cipherBytes = Hex.decodeHex(CIPHER_TEXT as char[])
+ final def INVALID_SALTS = ['argon2', '$3a$11$', 'x', '$2a$10$']
+ final LENGTH_MESSAGE = "The raw salt must be greater than or equal to 8 bytes"
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("adfc156f88ee8429a1c57879cb80b89a" 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 = new String(BCrypt.withDefaults().hash(WORK_FACTOR, BcryptCipherProvider.extractRawSalt(FULL_SALT.bytes), PASSWORD.bytes))
- 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}")
+ INVALID_SALTS.each { String salt ->
+ logger.info("Checking salt ${salt}")
- // Assert
- assert PLAINTEXT.equals(recovered)
+ 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
- void testGetCipherShouldHandleFullSalt() throws Exception {
+ void testGetCipherShouldHandleUnformattedSalts() throws Exception {
// Arrange
- final int WORK_FACTOR = 10
- RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(WORK_FACTOR)
-
- 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("57b017f9c0fdf8f0341424477e6baac1" as char[])
-
- // $v2$w2$base64_salt_22__base64_hash_31
- final String FULL_HASH = "\$2a\$10\$9XUQnxGEUsRdLqEhxY3xNujOQQkW3spKqxssi.Ox39VhhxB.z4496"
- 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 = "e51fee301593cc06b3112a7b9a16ab71be113c47d2b17cccc1b31a6e3b5a1631"
- byte[] cipherBytes = Hex.decodeHex(CIPHER_TEXT as char[])
+ 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()}")
- 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}")
+ RECOVERABLE_SALTS.each { String salt ->
+ logger.info("Checking salt ${salt}")
- // Assert
- assert PLAINTEXT.equals(recovered)
+ Cipher cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, salt.bytes, DEFAULT_KEY_LENGTH, true)
+
+ // Assert
+ assert cipher
+ }
}
@Test
- void testGetCipherShouldHandleUnformedSalt() throws Exception {
+ void testGetCipherShouldRejectEmptySalt() 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("")
- }
+ def msg =
+ shouldFail(IllegalArgumentException) {
+ Cipher cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, new byte[0], DEFAULT_KEY_LENGTH, true)
+ }
+ logger.expected(msg)
- String spaceString(String input, int blockSize = 4) {
- input.collect { it.padLeft(blockSize, " ") }.join("")
+ // Assert
+ assert msg =~ "The salt cannot be empty. To generate a salt, use Argon2CipherProvider#generateSalt()"
}
@Test
- void testGetCipherShouldRejectEmptySalt() throws Exception {
+ void testGenerateSaltShouldProvideValidSalt() throws Exception {
// Arrange
- RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(4)
+ RandomIVPBECipherProvider cipherProvider = new Argon2CipherProvider()
- final String PASSWORD = "thisIsABadPassword"
+ // Act
+ byte[] saltBytes = cipherProvider.generateSalt()
+ logger.info("Generated salt ${Hex.encodeHexString(saltBytes)}")
- EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
- logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()}")
+ String fullSalt = new String(saltBytes, StandardCharsets.UTF_8)
+ logger.info("Generated salt (${saltBytes.length}): ${fullSalt}".toString())
- // Two different errors -- one explaining the no-salt method is not supported, and the other for an empty byte[] passed
+ def rawSaltB64 = (fullSalt =~ /\$([\w\+\/]+)\$?$/)[0][1]
+ logger.info("Extracted B64 raw salt (${rawSaltB64.size()}): ${rawSaltB64}".toString())
- // Act
- def msg = shouldFail(IllegalArgumentException) {
- Cipher cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, new byte[0], DEFAULT_KEY_LENGTH, true)
- }
- logger.expected(msg)
+ byte[] rawSaltBytes = Base64.decodeBase64(rawSaltB64)
// Assert
- assert msg =~ "The salt cannot be empty\\. To generate a salt, use BcryptCipherProvider#generateSalt"
+ boolean isValidFormattedSalt = cipherProvider.isArgon2FormattedSalt(fullSalt)
+ logger.info("Salt is Argon2 format: ${isValidFormattedSalt}")
+ assert isValidFormattedSalt
+
+ boolean fullSaltIsValidLength = FULL_SALT_LENGTH_RANGE.contains(saltBytes.length)
+ logger.info("Salt length (${fullSalt.length()}) in valid range (${FULL_SALT_LENGTH_RANGE})")
+ assert fullSaltIsValidLength
+
+ assert rawSaltBytes != [(0x00 as byte) * 16]
}
@Test
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[])
@@ -376,6 +385,7 @@ class BcryptCipherProviderGroovyTest {
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"
@@ -385,15 +395,12 @@ class BcryptCipherProviderGroovyTest {
@Test
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
@@ -421,15 +428,12 @@ class BcryptCipherProviderGroovyTest {
@Test
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
@@ -441,6 +445,7 @@ class BcryptCipherProviderGroovyTest {
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"
@@ -448,90 +453,84 @@ class BcryptCipherProviderGroovyTest {
}
@Test
- void testGenerateSaltShouldUseProvidedWorkFactor() throws Exception {
+ void testArgon2ShouldNotAcceptInvalidPassword() {
// Arrange
- RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(11)
- int workFactor = cipherProvider.getWorkFactor()
+ String badPassword = ""
+ byte[] salt = [0x01 as byte] * 16
+
+ EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
// Act
- final byte[] saltBytes = cipherProvider.generateSalt()
- String salt = new String(saltBytes)
- logger.info("Salt: ${salt}")
+ def msg = shouldFail(IllegalArgumentException) {
+ cipherProvider.getCipher(encryptionMethod, badPassword, salt, DEFAULT_KEY_LENGTH, true)
+ }
// Assert
- assert salt =~ /^\$2[axy]\$\d{2}\$/
- assert salt.contains("\$${workFactor}\$")
+ assert msg =~ "Encryption with an empty password is not supported"
}
- @Ignore("This test can be run on a specific machine to evaluate if the default work factor is sufficient")
@Test
- void testDefaultConstructorShouldProvideStrongWorkFactor() {
+ void testShouldParseSalt() throws Exception {
// Arrange
- RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider()
+ cipherProvider = cipherProvider as Argon2CipherProvider
- // Values taken from http://wildlyinaccurate.com/bcrypt-choosing-a-work-factor/ and http://security.stackexchange.com/questions/17207/recommended-of-rounds-for-bcrypt
+ final byte[] EXPECTED_RAW_SALT = Hex.decodeHex("8622b26906d9c900660a60f5cc673233" as char[])
+ final int EXPECTED_MEMORY = 1024
+ final int EXPECTED_PARALLELISM = 4
+ final int EXPECTED_ITERATIONS = 1
- // Calculate the work factor to reach 500 ms
- int minimumWorkFactor = calculateMinimumWorkFactor()
- logger.info("Determined minimum safe work factor to be ${minimumWorkFactor}")
+ final String FORMATTED_SALT = "\$argon2id\$v=19\$m=1024,t=4,p=1\$hiKyaQbZyQBmCmD1zGcyMw"
+ logger.info("Using salt: ${FORMATTED_SALT}")
+
+ byte[] rawSalt = new byte[16]
+ def params = []
// Act
- int workFactor = cipherProvider.getWorkFactor()
- logger.info("Default work factor ${workFactor}")
+ cipherProvider.parseSalt(FORMATTED_SALT, rawSalt, params)
// Assert
- assertTrue("The default work factor for BcryptCipherProvider is too weak. Please update the default value to a stronger level.", workFactor >= minimumWorkFactor)
+ assert rawSalt == EXPECTED_RAW_SALT
+ assert params[0] == EXPECTED_MEMORY
+ assert params[1] == EXPECTED_PARALLELISM
+ assert params[2] == EXPECTED_ITERATIONS
}
- /**
- * 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
+ @Test
+ void testShouldRejectInvalidSalt() throws Exception {
+ // Arrange
+ cipherProvider = cipherProvider as Argon2CipherProvider
- // Benchmark using a work factor of 5 (the second-lowest allowed)
- int workFactor = 5
+ final String FULL_HASH = "\$argon2id\$v=19\$m=1024,t=4,p=1\$hiKyaQbZyQBmCmD1zGcyMw\$rc+ec+/hQeBcwzjH+OEmUtaTUqhZYKN4ZKJtWzFZYjQ"
+ logger.info("Using salt: ${FULL_HASH}")
- String salt = new BcryptCipherProvider(5).generateSalt()
+ byte[] rawSalt = new byte[16]
+ List<Integer> params = []
- // 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)")
+ // Act
+ boolean isValid = cipherProvider.isArgon2FormattedSalt(FULL_HASH)
+ logger.info("Argon2 formatted salt: ${isValid}")
- def durations = []
+ // Assert
+ assert !isValid
+ }
- RUNS.times { int i ->
- duration = time {
- BCrypt.hashpw(MICROBENCHMARK, salt)
- }
- logger.info("Work factor ${workFactor} took ${duration} ms")
- durations << duration
- }
+ @Test
+ void testShouldExtractSalt() throws Exception {
+ // Arrange
+ cipherProvider = cipherProvider as Argon2CipherProvider
- duration = durations.sum() / durations.size()
- logger.info("Work factor ${workFactor} averaged ${duration} ms")
+ final byte[] EXPECTED_RAW_SALT = Hex.decodeHex("8622b26906d9c900660a60f5cc673233" as char[])
- // 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
- }
+ final String FORMATTED_SALT = "\$argon2id\$v=19\$m=1024,t=4,p=1\$hiKyaQbZyQBmCmD1zGcyMw"
+ logger.info("Using salt: ${FORMATTED_SALT}")
- logger.info("Returning work factor ${workFactor} for ${duration} ms")
+ byte[] rawSalt
- return workFactor
- }
+ // Act
+ rawSalt = cipherProvider.extractRawSaltFromArgon2Salt(FORMATTED_SALT)
+ logger.info("rawSalt: ${Hex.encodeHexString(rawSalt)}")
- private static double time(Closure c) {
- long start = System.nanoTime()
- c.call()
- long end = System.nanoTime()
- return (end - start) / 1_000_000.0
+ // Assert
+ assert rawSalt == EXPECTED_RAW_SALT
}
}
\ No newline at end of file
diff --git a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/Argon2SecureHasherTest.groovy b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/Argon2SecureHasherTest.groovy
index 2a02a1c..c445ac0 100644
--- a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/Argon2SecureHasherTest.groovy
+++ b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/Argon2SecureHasherTest.groovy
@@ -28,6 +28,7 @@ import org.junit.runners.JUnit4
import org.slf4j.Logger
import org.slf4j.LoggerFactory
+import java.nio.charset.StandardCharsets
import java.security.Security
@RunWith(JUnit4.class)
@@ -143,13 +144,110 @@ class Argon2SecureHasherTest extends GroovyTestCase {
}
@Test
+ void testShouldHandleArbitrarySalt() {
+ // Arrange
+ int hashLength = 32
+ int memory = 8
+ int parallelism = 4
+ int iterations = 4
+ logger.info("Generating Argon2 hash for hash length: ${hashLength} B, mem: ${memory} KiB, parallelism: ${parallelism}, iterations: ${iterations}")
+
+ def input = "This is a sensitive value"
+ byte[] inputBytes = input.bytes
+
+ final String EXPECTED_HASH_HEX = "a73a471f51b2900901a00b81e770b9c1dfc595602bb7aec64cd27754a4174919"
+ logger.info("Expected Hash Hex length: ${EXPECTED_HASH_HEX.length()}")
+ final String EXPECTED_HASH_BASE64 = "pzpHH1GykAkBoAuB53C5wd/FlWArt67GTNJ3VKQXSRk"
+ final byte[] EXPECTED_HASH_BYTES = Hex.decode(EXPECTED_HASH_HEX)
+
+ // Static salt instance
+ Argon2SecureHasher staticSaltHasher = new Argon2SecureHasher(hashLength, memory, parallelism, iterations)
+ Argon2SecureHasher arbitrarySaltHasher = new Argon2SecureHasher(hashLength, memory, parallelism, iterations, 16)
+
+ final byte[] STATIC_SALT = AbstractSecureHasher.STATIC_SALT
+ final String DIFFERENT_STATIC_SALT = "Diff Static Salt"
+
+ // Act
+ byte[] staticSaltHash = staticSaltHasher.hashRaw(inputBytes)
+ byte[] arbitrarySaltHash = arbitrarySaltHasher.hashRaw(inputBytes, STATIC_SALT)
+ byte[] differentArbitrarySaltHash = arbitrarySaltHasher.hashRaw(inputBytes, DIFFERENT_STATIC_SALT.getBytes(StandardCharsets.UTF_8))
+ byte[] differentSaltHash = arbitrarySaltHasher.hashRaw(inputBytes)
+
+ String staticSaltHashHex = staticSaltHasher.hashHex(input)
+ String arbitrarySaltHashHex = arbitrarySaltHasher.hashHex(input, new String(STATIC_SALT, StandardCharsets.UTF_8))
+ String differentArbitrarySaltHashHex = arbitrarySaltHasher.hashHex(input, DIFFERENT_STATIC_SALT)
+ String differentSaltHashHex = arbitrarySaltHasher.hashHex(input)
+
+ String staticSaltHashBase64 = staticSaltHasher.hashBase64(input)
+ String arbitrarySaltHashBase64 = arbitrarySaltHasher.hashBase64(input, new String(STATIC_SALT, StandardCharsets.UTF_8))
+ String differentArbitrarySaltHashBase64 = arbitrarySaltHasher.hashBase64(input, DIFFERENT_STATIC_SALT)
+ String differentSaltHashBase64 = arbitrarySaltHasher.hashBase64(input)
+
+ // Assert
+ assert staticSaltHash == EXPECTED_HASH_BYTES
+ assert arbitrarySaltHash == EXPECTED_HASH_BYTES
+ assert differentArbitrarySaltHash != EXPECTED_HASH_BYTES
+ assert differentSaltHash != EXPECTED_HASH_BYTES
+
+ assert staticSaltHashHex == EXPECTED_HASH_HEX
+ assert arbitrarySaltHashHex == EXPECTED_HASH_HEX
+ assert differentArbitrarySaltHashHex != EXPECTED_HASH_HEX
+ assert differentSaltHashHex != EXPECTED_HASH_HEX
+
+ assert staticSaltHashBase64 == EXPECTED_HASH_BASE64
+ assert arbitrarySaltHashBase64 == EXPECTED_HASH_BASE64
+ assert differentArbitrarySaltHashBase64 != EXPECTED_HASH_BASE64
+ assert differentSaltHashBase64 != EXPECTED_HASH_BASE64
+ }
+
+ @Test
+ void testShouldValidateArbitrarySalt() {
+ // Arrange
+ int hashLength = 32
+ int memory = 8
+ int parallelism = 4
+ int iterations = 4
+ logger.info("Generating Argon2 hash for hash length: ${hashLength} B, mem: ${memory} KiB, parallelism: ${parallelism}, iterations: ${iterations}")
+
+ def input = "This is a sensitive value"
+ byte[] inputBytes = input.bytes
+
+ // Static salt instance
+ Argon2SecureHasher secureHasher = new Argon2SecureHasher(hashLength, memory, parallelism, iterations, 16)
+ final byte[] STATIC_SALT = "bad_sal".bytes
+
+ // Act
+ def initializeMsg = shouldFail(IllegalArgumentException) {
+ Argon2SecureHasher invalidSaltLengthHasher = new Argon2SecureHasher(hashLength, memory, parallelism, iterations, 7)
+ }
+ logger.expected(initializeMsg)
+
+ def arbitrarySaltRawMsg = shouldFail {
+ byte[] arbitrarySaltRaw = secureHasher.hashRaw(inputBytes, STATIC_SALT)
+ }
+
+ def arbitrarySaltHexMsg = shouldFail {
+ byte[] arbitrarySaltHex = secureHasher.hashHex(input, new String(STATIC_SALT, StandardCharsets.UTF_8))
+ }
+
+ def arbitrarySaltBase64Msg = shouldFail {
+ byte[] arbitraySaltBase64 = secureHasher.hashBase64(input, new String(STATIC_SALT, StandardCharsets.UTF_8))
+ }
+
+ def results = [arbitrarySaltRawMsg, arbitrarySaltHexMsg, arbitrarySaltBase64Msg]
+
+ // Assert
+ assert results.every { it =~ /The salt length \(7 bytes\) is invalid/ }
+ }
+
+ @Test
void testShouldFormatHex() {
// Arrange
String input = "This is a sensitive value"
final String EXPECTED_HASH_HEX = "0c2920c52f28e0a2c77d006ec6138c8dc59580881468b85541cf886abdebcf18"
- Argon2SecureHasher a2sh = new Argon2SecureHasher()
+ Argon2SecureHasher a2sh = new Argon2SecureHasher(32, 4096, 1, 3)
// Act
String hashHex = a2sh.hashHex(input)
@@ -164,9 +262,9 @@ class Argon2SecureHasherTest extends GroovyTestCase {
// Arrange
String input = "This is a sensitive value"
- final String EXPECTED_HASH_B64 = "DCkgxS8o4KLHfQBuxhOMjcWVgIgUaLhVQc+Iar3rzxg="
+ final String EXPECTED_HASH_B64 = "DCkgxS8o4KLHfQBuxhOMjcWVgIgUaLhVQc+Iar3rzxg"
- Argon2SecureHasher a2sh = new Argon2SecureHasher()
+ Argon2SecureHasher a2sh = new Argon2SecureHasher(32, 4096, 1, 3)
// Act
String hashB64 = a2sh.hashBase64(input)
@@ -182,9 +280,9 @@ class Argon2SecureHasherTest extends GroovyTestCase {
List<String> inputs = [null, ""]
final String EXPECTED_HASH_HEX = "8e5625a66b94ed9d31c1496d7f9ff49249cf05d6753b50ba0e2bf2a1108973dd"
- final String EXPECTED_HASH_B64 = "jlYlpmuU7Z0xwUltf5/0kknPBdZ1O1C6DivyoRCJc90="
+ final String EXPECTED_HASH_B64 = "jlYlpmuU7Z0xwUltf5/0kknPBdZ1O1C6DivyoRCJc90"
- Argon2SecureHasher a2sh = new Argon2SecureHasher()
+ Argon2SecureHasher a2sh = new Argon2SecureHasher(32, 4096, 1, 3)
def hexResults = []
def b64Results = []
@@ -213,10 +311,10 @@ class Argon2SecureHasherTest extends GroovyTestCase {
@Test
void testDefaultCostParamsShouldBeSufficient() {
// Arrange
- int testIterations = 10_000
+ int testIterations = 100 //_000
byte[] inputBytes = "This is a sensitive value".bytes
- Argon2SecureHasher a2sh = new Argon2SecureHasher()
+ Argon2SecureHasher a2sh = new Argon2SecureHasher(16, 2**16, 8, 5)
def results = []
def resultDurations = []
@@ -235,8 +333,11 @@ class Argon2SecureHasherTest extends GroovyTestCase {
resultDurations << durationNanos
}
+ def milliDurations = [resultDurations.min(), resultDurations.max(), resultDurations.sum()/resultDurations.size()].collect { it / 1_000_000 }
+ logger.info("Min/Max/Avg durations in ms: ${milliDurations}")
+
// Assert
- final long MIN_DURATION_NANOS = 5_000_000 // 5 ms
+ final long MIN_DURATION_NANOS = 500_000_000 // 500 ms
assert resultDurations.min() > MIN_DURATION_NANOS
assert resultDurations.sum() / testIterations > MIN_DURATION_NANOS
}
@@ -368,7 +469,7 @@ class Argon2SecureHasherTest extends GroovyTestCase {
// Act
def results = saltLengths.collect { saltLength ->
- def isValid = Argon2SecureHasher.isSaltLengthValid(saltLength)
+ def isValid = new Argon2SecureHasher().isSaltLengthValid(saltLength)
[saltLength, isValid]
}
@@ -386,7 +487,7 @@ class Argon2SecureHasherTest extends GroovyTestCase {
// Act
def results = saltLengths.collect { saltLength ->
- def isValid = Argon2SecureHasher.isSaltLengthValid(saltLength)
+ def isValid = new Argon2SecureHasher().isSaltLengthValid(saltLength)
[saltLength, isValid]
}
@@ -396,4 +497,27 @@ class Argon2SecureHasherTest extends GroovyTestCase {
assert !isSaltLengthValid
}
}
+
+ @Test
+ void testShouldCreateHashOfDesiredLength() throws Exception {
+ // Arrange
+ def hashLengths = [16, 32]
+
+ final String PASSWORD = "password"
+ final byte[] SALT = [0x00] * 16
+ final byte[] EXPECTED_HASH = Hex.decode("411c9c87e7c91d8c8eacc418665bd2e1")
+
+ // Act
+ Map<Integer, byte[]> results = hashLengths.collectEntries { hashLength ->
+ Argon2SecureHasher ash = new Argon2SecureHasher(hashLength, 8, 1, 3)
+ def hash = ash.hashRaw(PASSWORD.bytes, SALT)
+ logger.info("Hashed password ${PASSWORD} with salt ${Hex.encode(SALT)} to ${Hex.encode(hash)}".toString())
+ [hashLength, hash]
+ }
+
+ // Assert
+ assert results[16][0..15] != results[32][0..15]
+ // Demonstrates that internal hash truncation is not supported
+// assert results.every { int k, byte[] v -> v[0..15] as byte[] == EXPECTED_HASH}
+ }
}
diff --git a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/BcryptCipherProviderGroovyTest.groovy b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/BcryptCipherProviderGroovyTest.groovy
index 5aa5aec..b60c58c 100644
--- a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/BcryptCipherProviderGroovyTest.groovy
+++ b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/BcryptCipherProviderGroovyTest.groovy
@@ -18,11 +18,15 @@ package org.apache.nifi.security.util.crypto
import at.favre.lib.crypto.bcrypt.BCrypt
import at.favre.lib.crypto.bcrypt.Radix64Encoder
-import org.apache.commons.codec.binary.Base64
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.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
@@ -32,6 +36,7 @@ import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
import java.security.Security
import static groovy.test.GroovyAssert.shouldFail
@@ -183,11 +188,16 @@ class BcryptCipherProviderGroovyTest {
final int WORK_FACTOR = 10
// Act
- String calculatedHash = new String(BCrypt.withDefaults().hash(WORK_FACTOR, SALT, PASSWORD), StandardCharsets.UTF_8)
- logger.info("Generated ${calculatedHash}")
+ String libraryCalculatedHash = new String(BCrypt.withDefaults().hash(WORK_FACTOR, SALT, PASSWORD), StandardCharsets.UTF_8)
+ logger.info("Generated ${libraryCalculatedHash}")
+
+ BcryptSecureHasher bcryptSecureHasher = new BcryptSecureHasher(WORK_FACTOR)
+ String secureHasherCalculatedHash = new String(bcryptSecureHasher.hashRaw(PASSWORD, SALT), StandardCharsets.UTF_8)
+ logger.info("Generated ${secureHasherCalculatedHash}")
// Assert
- assert calculatedHash == EXPECTED_HASH
+ assert secureHasherCalculatedHash == EXPECTED_HASH
+ assert secureHasherCalculatedHash == EXPECTED_HASH
}
@Test
@@ -200,23 +210,24 @@ class BcryptCipherProviderGroovyTest {
final String PASSWORD = "thisIsABadPassword"
// These values can be generated by running `$ ./openssl_bcrypt` in the terminal
- final byte[] SALT = Hex.decodeHex("f575109f118452c45d2ea121c58df136" as char[])
- final byte[] IV = Hex.decodeHex("57b017f9c0fdf8f0341424477e6baac1" as char[])
+ // The Ruby bcrypt gem does not expose the custom Radix64 decoder, so maintain the R64 encoding from the output and decode here
+ final byte[] SALT = new Radix64Encoder.Default().decode("LBVzJoPgh.85YCvnos4BKO".bytes)
+ final byte[] IV = Hex.decodeHex("bae8a9d935748a75ff0e0bbd95a4f024" as char[])
// $v2$w2$base64_salt_22__base64_hash_31
- final String FULL_HASH = "\$2a\$10\$9XUQnxGEUsRdLqEhxY3xNujOQQkW3spKqxssi.Ox39VhhxB.z4496"
+ final String FULL_HASH = "\$2a\$10\$LBVzJoPgh.85YCvnos4BKOyYM.LRni6UbU4v/CEPBkmFIiigADJZi"
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, " ")}")
+ logger.info(" B64 Salt: ${customB64Encode(SALT).padLeft(29, " ")}")
String extractedSalt = FULL_HASH[7..<29]
logger.info("Extracted Salt: ${extractedSalt}")
- String extractedSaltHex = Hex.encodeHexString(Base64.decodeBase64(extractedSalt))
+ String extractedSaltHex = Hex.encodeHexString(customB64Decode(extractedSalt))
logger.info("Extracted Salt (hex): ${extractedSaltHex}")
logger.info(" Expected Salt (hex): ${Hex.encodeHexString(SALT)}")
- final String CIPHER_TEXT = "e51fee301593cc06b3112a7b9a16ab71be113c47d2b17cccc1b31a6e3b5a1631"
+ final String CIPHER_TEXT = "d232b68e7aa38242d195c54b8f360d8b8d6b7580b190ffdeef99f5fe460bd6b0"
byte[] cipherBytes = Hex.decodeHex(CIPHER_TEXT as char[])
EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
@@ -225,7 +236,7 @@ class BcryptCipherProviderGroovyTest {
// Sanity check
Cipher rubyCipher = Cipher.getInstance(encryptionMethod.algorithm, "BC")
- def rubyKey = new SecretKeySpec(Hex.decodeHex("adfc156f88ee8429a1c57879cb80b89a" as char[]), "AES")
+ def rubyKey = new SecretKeySpec(Hex.decodeHex("01ea96ccc48a1d045bd7f461721b94a8" as char[]), "AES")
def ivSpec = new IvParameterSpec(IV)
rubyCipher.init(Cipher.ENCRYPT_MODE, rubyKey, ivSpec)
byte[] rubyCipherBytes = rubyCipher.doFinal(PLAINTEXT.bytes)
@@ -238,7 +249,7 @@ class BcryptCipherProviderGroovyTest {
// Sanity for hash generation
final String FULL_SALT = FULL_HASH[0..<29]
logger.sanity("Salt from external: ${FULL_SALT}")
- String generatedHash = new String(BCrypt.withDefaults().hash(WORK_FACTOR, BcryptCipherProvider.extractRawSalt(FULL_SALT.bytes), PASSWORD.bytes))
+ String generatedHash = new String(BCrypt.withDefaults().hash(WORK_FACTOR, BcryptCipherProvider.extractRawSalt(FULL_SALT), PASSWORD.bytes))
logger.sanity("Generated hash: ${generatedHash}")
assert generatedHash == FULL_HASH
@@ -252,6 +263,23 @@ class BcryptCipherProviderGroovyTest {
assert PLAINTEXT.equals(recovered)
}
+ private static byte[] customB64Decode(String input) {
+ customB64Decode(input.bytes)
+ }
+
+ private static byte[] customB64Decode(byte[] input) {
+ new Radix64Encoder.Default().decode(input)
+ }
+
+ private static String customB64Encode(String input) {
+ customB64Encode(input.bytes)
+ }
+
+ private static String customB64Encode(byte[] input) {
+ new String(new Radix64Encoder.Default().encode(input), StandardCharsets.UTF_8)
+ }
+
+
@Test
void testGetCipherShouldHandleFullSalt() throws Exception {
// Arrange
@@ -262,10 +290,10 @@ class BcryptCipherProviderGroovyTest {
final String PASSWORD = "thisIsABadPassword"
// These values can be generated by running `$ ./openssl_bcrypt.rb` in the terminal
- final byte[] IV = Hex.decodeHex("57b017f9c0fdf8f0341424477e6baac1" as char[])
+ final byte[] IV = Hex.decodeHex("bae8a9d935748a75ff0e0bbd95a4f024" as char[])
// $v2$w2$base64_salt_22__base64_hash_31
- final String FULL_HASH = "\$2a\$10\$9XUQnxGEUsRdLqEhxY3xNujOQQkW3spKqxssi.Ox39VhhxB.z4496"
+ final String FULL_HASH = "\$2a\$10\$LBVzJoPgh.85YCvnos4BKOyYM.LRni6UbU4v/CEPBkmFIiigADJZi"
logger.info("Full Hash: ${FULL_HASH}")
final String FULL_SALT = FULL_HASH[0..<29]
logger.info(" Salt: ${FULL_SALT}")
@@ -274,10 +302,10 @@ class BcryptCipherProviderGroovyTest {
String extractedSalt = FULL_HASH[7..<29]
logger.info("Extracted Salt: ${extractedSalt}")
- String extractedSaltHex = Hex.encodeHexString(Base64.decodeBase64(extractedSalt))
+ String extractedSaltHex = Hex.encodeHexString(customB64Decode(extractedSalt))
logger.info("Extracted Salt (hex): ${extractedSaltHex}")
- final String CIPHER_TEXT = "e51fee301593cc06b3112a7b9a16ab71be113c47d2b17cccc1b31a6e3b5a1631"
+ final String CIPHER_TEXT = "d232b68e7aa38242d195c54b8f360d8b8d6b7580b190ffdeef99f5fe460bd6b0"
byte[] cipherBytes = Hex.decodeHex(CIPHER_TEXT as char[])
EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
@@ -313,6 +341,7 @@ class BcryptCipherProviderGroovyTest {
def msg = shouldFail(IllegalArgumentException) {
Cipher cipher = cipherProvider.getCipher(encryptionMethod, PASSWORD, salt.bytes, DEFAULT_KEY_LENGTH, true)
}
+ logger.expected(msg)
// Assert
assert msg =~ "The salt must be of the format \\\$2a\\\$10\\\$gUVbkVzp79H8YaCOsCVZNu\\. To generate a salt, use BcryptCipherProvider#generateSalt"
@@ -348,7 +377,7 @@ class BcryptCipherProviderGroovyTest {
logger.expected(msg)
// Assert
- assert msg =~ "The salt cannot be empty\\. To generate a salt, use BcryptCipherProvider#generateSalt"
+ assert msg =~ "The salt must be of the format .* To generate a salt, use BcryptCipherProvider#generateSalt"
}
@Test
@@ -463,6 +492,140 @@ class BcryptCipherProviderGroovyTest {
assert salt.contains("\$${workFactor}\$")
}
+ /**
+ * For {@code 1.12.0} the key derivation process was changed. Previously, the entire hash output
+ * ({@code $2a$10$9XUQnxGEUsRdLqEhxY3xNujOQQkW3spKqxssi.Ox39VhhxB.z4496}) was fed to {@code SHA-512}
+ * to stretch the hash output to a custom key length (128, 192, or 256 bits) because the Bcrypt hash
+ * output length is fixed at 184 bits. The new key derivation process only feeds the <em>non-salt
+ * hash output</em> (({@code jOQQkW3spKqxssi.Ox39VhhxB.z4496})) into the digest.
+ * @throws Exception
+ */
+ @Test
+ void testGetCipherShouldUseHashOutputOnlyToDeriveKey() throws Exception {
+ // Arrange
+ BcryptCipherProvider cipherProvider = new BcryptCipherProvider(4)
+
+ final String PASSWORD = "shortPassword"
+ final byte[] SALT = cipherProvider.generateSalt()
+ String saltString = new String(SALT, StandardCharsets.UTF_8)
+ logger.info("Using fixed Bcrypt salt: ${saltString}")
+
+ // Determine the expected key bytes using the new key derivation process
+ BcryptSecureHasher bcryptSecureHasher = new BcryptSecureHasher(cipherProvider.getWorkFactor(), cipherProvider.getDefaultSaltLength())
+ byte[] rawSaltBytes = BcryptCipherProvider.extractRawSalt(saltString)
+ byte[] hashOutputBytes = bcryptSecureHasher.hashRaw(PASSWORD.getBytes(StandardCharsets.UTF_8), rawSaltBytes)
+ logger.info("Raw hash output (${hashOutputBytes.length}): ${Hex.encodeHexString(hashOutputBytes)}")
+
+ MessageDigest sha512 = MessageDigest.getInstance("SHA-512", "BC")
+ byte[] keyDigestBytes = sha512.digest(hashOutputBytes[-31..-1] as byte[])
+ logger.info("Key digest (${keyDigestBytes.length}): ${Hex.encodeHexString(keyDigestBytes)}")
+
+ 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}")
+
+ // Expected key verification
+ int keyLength = CipherUtility.parseKeyLengthFromAlgorithm(em.getAlgorithm())
+ byte[] derivedKeyBytes = Arrays.copyOf(keyDigestBytes, keyLength / 8 as int)
+ logger.info("Derived key (${derivedKeyBytes.length}): ${Hex.encodeHexString(derivedKeyBytes)}")
+
+ Cipher verificationCipher = Cipher.getInstance(em.getAlgorithm())
+ verificationCipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(derivedKeyBytes, em.algorithm), new IvParameterSpec(iv))
+ byte[] verificationBytes = verificationCipher.doFinal(cipherBytes)
+ String verificationRecovered = new String(verificationBytes, StandardCharsets.UTF_8)
+ logger.info("Verified: ${verificationRecovered}")
+
+ // Assert
+ assert plaintext == recovered
+ assert plaintext == verificationRecovered
+ }
+ }
+
+ @Test
+ void testGetCipherShouldBeBackwardCompatibleWithFullHashKeyDerivation() 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 using the legacy key derivation process
+ Cipher cipher = cipherProvider.getInitializedCipher(em, PASSWORD, SALT, new byte[0], DEFAULT_KEY_LENGTH, true, 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.getLegacyDecryptCipher(em, PASSWORD, SALT, iv, DEFAULT_KEY_LENGTH)
+ byte[] recoveredBytes = cipher.doFinal(cipherBytes)
+ String recovered = new String(recoveredBytes, "UTF-8")
+ logger.info("Recovered: ${recovered}")
+
+ // Assert
+ assert plaintext == recovered
+ }
+ }
+
+ @Test
+ void testGetCipherShouldHandleNullSalt() throws Exception {
+ // Arrange
+ RandomIVPBECipherProvider cipherProvider = new BcryptCipherProvider(4)
+
+ final String PASSWORD = "shortPassword"
+ final byte[] SALT = null
+ final EncryptionMethod em = EncryptionMethod.AES_CBC
+
+ final String plaintext = "This is a plaintext message."
+
+ // Act
+ logger.info("Using algorithm: ${em.getAlgorithm()}")
+
+ // Initialize a cipher for encryption
+ def encryptMsg = shouldFail(IllegalArgumentException) {
+ Cipher cipher = cipherProvider.getCipher(em, PASSWORD, SALT, DEFAULT_KEY_LENGTH, true)
+ byte[] iv = cipher.getIV()
+ logger.info("IV: ${Hex.encodeHexString(iv)}")
+ }
+ logger.expected("Encrypt error: ${encryptMsg}")
+
+ byte[] cipherBytes = plaintext.reverse().getBytes(StandardCharsets.UTF_8)
+ logger.info("Cipher text: ${Hex.encodeHexString(cipherBytes)} ${cipherBytes.length}")
+
+ def decryptMsg = shouldFail(IllegalArgumentException) {
+ Cipher cipher = cipherProvider.getCipher(em, PASSWORD, SALT, [0x00] * 16 as byte[], DEFAULT_KEY_LENGTH, false)
+ byte[] recoveredBytes = cipher.doFinal(cipherBytes)
+ String recovered = new String(recoveredBytes, "UTF-8")
+ logger.info("Recovered: ${recovered}")
+ }
+ logger.expected("Decrypt error: ${decryptMsg}")
+
+ // Assert
+ assert encryptMsg =~ "The salt must be of the format"
+ assert decryptMsg =~ "The salt must be of the format"
+ }
+
@Ignore("This test can be run on a specific machine to evaluate if the default work factor is sufficient")
@Test
void testDefaultConstructorShouldProvideStrongWorkFactor() {
diff --git a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/BcryptSecureHasherTest.groovy b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/BcryptSecureHasherTest.groovy
new file mode 100644
index 0000000..68bd973
--- /dev/null
+++ b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/BcryptSecureHasherTest.groovy
@@ -0,0 +1,415 @@
+/*
+ * 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 at.favre.lib.crypto.bcrypt.Radix64Encoder
+import org.apache.kerby.util.Hex
+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.nio.charset.StandardCharsets
+import java.security.Security
+
+@RunWith(JUnit4.class)
+class BcryptSecureHasherTest extends GroovyTestCase {
+ private static final Logger logger = LoggerFactory.getLogger(BcryptSecureHasher)
+
+ @BeforeClass
+ static void setupOnce() throws Exception {
+ Security.addProvider(new BouncyCastleProvider())
+
+ logger.metaClass.methodMissing = { String name, args ->
+ logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}")
+ }
+ }
+
+ @Before
+ void setUp() throws Exception {
+ }
+
+ @After
+ void tearDown() throws Exception {
+ }
+
+ private static byte[] decodeHex(String hex) {
+ Hex.decode(hex?.replaceAll("[^0-9a-fA-F]", ""))
+ }
+
+ @Test
+ void testShouldBeDeterministicWithStaticSalt() {
+ // Arrange
+ int cost = 4
+ logger.info("Generating Bcrypt hash for cost factor: ${cost}")
+
+ int testIterations = 10
+ byte[] inputBytes = "This is a sensitive value".bytes
+
+ final String EXPECTED_HASH_HEX = "24326124303424526b6a4559512f526245447959554b6553304471622e596b4c5331655a2e6c61586550484c69464d783937564c566d47354250454f"
+
+ BcryptSecureHasher bcryptSH = new BcryptSecureHasher(cost)
+
+ def results = []
+
+ // Act
+ testIterations.times { int i ->
+ byte[] hash = bcryptSH.hashRaw(inputBytes)
+ String hashHex = Hex.encode(hash)
+ logger.info("Generated hash: ${hashHex}")
+ results << hashHex
+ }
+
+ // Assert
+ assert results.every { it == EXPECTED_HASH_HEX }
+ }
+
+ @Test
+ void testShouldBeDifferentWithRandomSalt() {
+ // Arrange
+ int cost = 4
+ int saltLength = 16
+ logger.info("Generating Bcrypt hash for cost factor: ${cost}, salt length: ${saltLength}")
+
+ int testIterations = 10
+ byte[] inputBytes = "This is a sensitive value".bytes
+
+ final String EXPECTED_HASH_HEX = "24326124303424546d6c47615342546447463061574d6755324673642e38675a347a6149356d6b4d50594c542e344e68337962455a4678384b676a75"
+
+ BcryptSecureHasher bcryptSH = new BcryptSecureHasher(cost, saltLength)
+
+ def results = []
+
+ // Act
+ testIterations.times { int i ->
+ byte[] hash = bcryptSH.hashRaw(inputBytes)
+ String hashHex = Hex.encode(hash)
+ logger.info("Generated hash: ${hashHex}")
+ results << hashHex
+ }
+
+ // Assert
+ assert results.unique().size() == results.size()
+ assert results.every { it != EXPECTED_HASH_HEX }
+ }
+
+ @Test
+ void testShouldHandleArbitrarySalt() {
+ // Arrange
+ int cost = 4
+ logger.info("Generating Bcrypt hash for cost factor: ${cost}")
+
+ def input = "This is a sensitive value"
+ byte[] inputBytes = input.bytes
+
+ final String EXPECTED_HASH_HEX = "24326124303424526b6a4559512f526245447959554b6553304471622e596b4c5331655a2e6c61586550484c69464d783937564c566d47354250454f"
+ final String EXPECTED_HASH_BASE64 = "JDJhJDA0JFJrakVZUS9SYkVEeVlVS2VTMERxYi5Za0xTMWVaLmxhWGVQSExpRk14OTdWTFZtRzVCUEVP"
+ final byte[] EXPECTED_HASH_BYTES = Hex.decode(EXPECTED_HASH_HEX)
+
+ // Static salt instance
+ BcryptSecureHasher staticSaltHasher = new BcryptSecureHasher(cost)
+ BcryptSecureHasher arbitrarySaltHasher = new BcryptSecureHasher(cost, 16)
+
+ final byte[] STATIC_SALT = AbstractSecureHasher.STATIC_SALT
+ final String DIFFERENT_STATIC_SALT = "Diff Static Salt"
+
+ // Act
+ byte[] staticSaltHash = staticSaltHasher.hashRaw(inputBytes)
+ byte[] arbitrarySaltHash = arbitrarySaltHasher.hashRaw(inputBytes, STATIC_SALT)
+ byte[] differentArbitrarySaltHash = arbitrarySaltHasher.hashRaw(inputBytes, DIFFERENT_STATIC_SALT.getBytes(StandardCharsets.UTF_8))
+ byte[] differentSaltHash = arbitrarySaltHasher.hashRaw(inputBytes)
+
+ String staticSaltHashHex = staticSaltHasher.hashHex(input)
+ String arbitrarySaltHashHex = arbitrarySaltHasher.hashHex(input, new String(STATIC_SALT, StandardCharsets.UTF_8))
+ String differentArbitrarySaltHashHex = arbitrarySaltHasher.hashHex(input, DIFFERENT_STATIC_SALT)
+ String differentSaltHashHex = arbitrarySaltHasher.hashHex(input)
+
+ String staticSaltHashBase64 = staticSaltHasher.hashBase64(input)
+ String arbitrarySaltHashBase64 = arbitrarySaltHasher.hashBase64(input, new String(STATIC_SALT, StandardCharsets.UTF_8))
+ String differentArbitrarySaltHashBase64 = arbitrarySaltHasher.hashBase64(input, DIFFERENT_STATIC_SALT)
+ String differentSaltHashBase64 = arbitrarySaltHasher.hashBase64(input)
+
+ // Assert
+ assert staticSaltHash == EXPECTED_HASH_BYTES
+ assert arbitrarySaltHash == EXPECTED_HASH_BYTES
+ assert differentArbitrarySaltHash != EXPECTED_HASH_BYTES
+ assert differentSaltHash != EXPECTED_HASH_BYTES
+
+ assert staticSaltHashHex == EXPECTED_HASH_HEX
+ assert arbitrarySaltHashHex == EXPECTED_HASH_HEX
+ assert differentArbitrarySaltHashHex != EXPECTED_HASH_HEX
+ assert differentSaltHashHex != EXPECTED_HASH_HEX
+
+ assert staticSaltHashBase64 == EXPECTED_HASH_BASE64
+ assert arbitrarySaltHashBase64 == EXPECTED_HASH_BASE64
+ assert differentArbitrarySaltHashBase64 != EXPECTED_HASH_BASE64
+ assert differentSaltHashBase64 != EXPECTED_HASH_BASE64
+
+ }
+
+ @Test
+ void testShouldValidateArbitrarySalt() {
+ // Arrange
+ int cost = 4
+ logger.info("Generating Bcrypt hash for cost factor: ${cost}")
+
+ def input = "This is a sensitive value"
+ byte[] inputBytes = input.bytes
+
+ // Static salt instance
+ BcryptSecureHasher secureHasher = new BcryptSecureHasher(cost, 16)
+ final byte[] STATIC_SALT = "bad_sal".bytes
+
+ // Act
+ def initializationMsg = shouldFail(IllegalArgumentException) {
+ BcryptSecureHasher invalidSaltLengthHasher = new BcryptSecureHasher(cost, 7)
+ }
+ logger.expected(initializationMsg)
+
+ def arbitrarySaltRawMsg = shouldFail {
+ byte[] arbitrarySaltHash = secureHasher.hashRaw(inputBytes, STATIC_SALT)
+ }
+
+ def arbitrarySaltHexMsg = shouldFail {
+ byte[] arbitrarySaltHashHex = secureHasher.hashHex(input, new String(STATIC_SALT, StandardCharsets.UTF_8))
+ }
+
+ def arbitrarySaltBase64Msg = shouldFail {
+ byte[] arbitrarySaltBase64 = secureHasher.hashBase64(input, new String(STATIC_SALT, StandardCharsets.UTF_8))
+ }
+
+ def results = [arbitrarySaltRawMsg, arbitrarySaltHexMsg, arbitrarySaltBase64Msg]
+
+ // Assert
+ assert results.every { it =~ /The salt length \(7 bytes\) is invalid/ }
+ }
+
+ @Test
+ void testShouldFormatHex() {
+ // Arrange
+ String input = "This is a sensitive value"
+
+ final String EXPECTED_HASH_HEX = "24326124313224526b6a4559512f526245447959554b6553304471622e5852696135344d4e356c5a44515243575874516c4c696d476669635a776871"
+
+ BcryptSecureHasher bcryptSH = new BcryptSecureHasher()
+
+ // Act
+ String hashHex = bcryptSH.hashHex(input)
+ logger.info("Generated hash: ${hashHex}")
+
+ // Assert
+ assert hashHex == EXPECTED_HASH_HEX
+ }
+
+ @Test
+ void testShouldFormatBase64() {
+ // Arrange
+ String input = "This is a sensitive value"
+
+ final String EXPECTED_HASH_BASE64 = "JDJhJDEyJFJrakVZUS9SYkVEeVlVS2VTMERxYi5YUmlhNTRNTjVsWkRRUkNXWHRRbExpbUdmaWNad2hx"
+
+ BcryptSecureHasher bcryptSH = new BcryptSecureHasher()
+
+ // Act
+ String hashB64 = bcryptSH.hashBase64(input)
+ logger.info("Generated hash: ${hashB64}")
+
+ // Assert
+ assert hashB64 == EXPECTED_HASH_BASE64
+ }
+
+ @Test
+ void testShouldHandleNullInput() {
+ // Arrange
+ List<String> inputs = [null, ""]
+
+ final String EXPECTED_HASH_HEX = ""
+ final String EXPECTED_HASH_BASE64 = ""
+
+ BcryptSecureHasher bcryptSH = new BcryptSecureHasher()
+
+ def hexResults = []
+ def B64Results = []
+
+ // Act
+ inputs.each { String input ->
+ String hashHex = bcryptSH.hashHex(input)
+ logger.info("Generated hex-encoded hash: ${hashHex}")
+ hexResults << hashHex
+
+ String hashB64 = bcryptSH.hashBase64(input)
+ logger.info("Generated B64-encoded hash: ${hashB64}")
+ B64Results << hashB64
+ }
+
+ // Assert
+ assert hexResults.every { it == EXPECTED_HASH_HEX }
+ assert B64Results.every { it == EXPECTED_HASH_BASE64 }
+ }
+
+ /**
+ * This test can have the minimum time threshold updated to determine if the performance
+ * is still sufficient compared to the existing threat model.
+ */
+ @Ignore("Long running test")
+ @Test
+ void testDefaultCostParamsShouldBeSufficient() {
+ // Arrange
+ int testIterations = 100
+ byte[] inputBytes = "This is a sensitive value".bytes
+
+ BcryptSecureHasher bcryptSH = new BcryptSecureHasher()
+
+ def results = []
+ def resultDurations = []
+
+ // Act
+ testIterations.times { int i ->
+ long startNanos = System.nanoTime()
+ byte[] hash = bcryptSH.hashRaw(inputBytes)
+ long endNanos = System.nanoTime()
+ long durationNanos = endNanos - startNanos
+
+ String hashHex = Hex.encode(hash)
+ logger.info("Generated hash: ${hashHex} in ${durationNanos} ns")
+
+ results << hashHex
+ resultDurations << durationNanos
+ }
+
+ // Assert
+ final long MIN_DURATION_NANOS = 75_000_000 // 75 ms
+ assert resultDurations.min() > MIN_DURATION_NANOS
+ assert resultDurations.sum() / testIterations > MIN_DURATION_NANOS
+ }
+
+ @Test
+ void testShouldVerifyCostBoundary() throws Exception {
+ // Arrange
+ final int cost = 14
+
+ // Act
+ boolean valid = BcryptSecureHasher.isCostValid(cost)
+
+ // Assert
+ assert valid
+ }
+
+ @Test
+ void testShouldFailCostBoundary() throws Exception {
+ // Arrange
+ def costFactors = [-8, 0, 40]
+
+ // Act
+ def results = costFactors.collect { costFactor ->
+ def isValid = BcryptSecureHasher.isCostValid(costFactor)
+ [costFactor, isValid]
+ }
+
+ // Assert
+ results.each { costFactor, isCostValid ->
+ logger.info("For cost factor ${costFactor}, cost is ${isCostValid ? "valid" : "invalid"}")
+ assert !isCostValid
+ }
+ }
+
+ @Test
+ void testShouldVerifySaltLengthBoundary() throws Exception {
+ // Arrange
+ def saltLengths = [0, 16]
+
+ // Act
+ def results = saltLengths.collect { saltLength ->
+ def isValid = new BcryptSecureHasher().isSaltLengthValid(saltLength)
+ [saltLength, isValid]
+ }
+
+ // Assert
+ results.each { saltLength, isSaltLengthValid ->
+ assert { it == isSaltLengthValid }
+ }
+ }
+
+ @Test
+ void testShouldFailSaltLengthBoundary() throws Exception {
+ // Arrange
+ def saltLengths = [-8, 1]
+
+ // Act
+ def results = saltLengths.collect { saltLength ->
+ def isValid = new BcryptSecureHasher().isSaltLengthValid(saltLength)
+ [saltLength, isValid]
+ }
+
+ // Assert
+ results.each { saltLength, isSaltLengthValid ->
+ logger.info("For Salt Length value ${saltLength}, saltLength is ${isSaltLengthValid ? "valid" : "invalid"}")
+ assert !isSaltLengthValid
+ }
+ }
+
+ @Test
+ void testShouldConvertRadix64ToBase64() {
+ // Arrange
+ final String INPUT_RADIX_64 = "mm7MiKjvXVYCujVUlKRKiu"
+ final byte[] EXPECTED_BYTES = new Radix64Encoder.Default().decode(INPUT_RADIX_64.bytes)
+ logger.info("Plain bytes: ${Hex.encode(EXPECTED_BYTES)}")
+
+ // Uses standard Base64 library but removes padding chars
+ final String EXPECTED_MIME_B64 = Base64.encoder.encodeToString(EXPECTED_BYTES).replaceAll(/=/, '')
+
+ // Act
+ String convertedBase64 = BcryptSecureHasher.convertBcryptRadix64ToMimeBase64(INPUT_RADIX_64)
+ logger.info("Converted (R64) ${INPUT_RADIX_64} to (B64) ${convertedBase64}")
+
+ String convertedRadix64 = BcryptSecureHasher.convertMimeBase64ToBcryptRadix64(convertedBase64)
+ logger.info("Converted (B64) ${convertedBase64} to (R64) ${convertedRadix64}")
+
+ // Assert
+ assert convertedBase64 == EXPECTED_MIME_B64
+ assert convertedRadix64 == INPUT_RADIX_64
+ }
+
+ @Test
+ void testConvertRadix64ToBase64ShouldHandlePeriod() {
+ // Arrange
+ final String INPUT_RADIX_64 = "75x373yP7atxMD3pVgsdO."
+ final byte[] EXPECTED_BYTES = new Radix64Encoder.Default().decode(INPUT_RADIX_64.bytes)
+ logger.info("Plain bytes: ${Hex.encode(EXPECTED_BYTES)}")
+
+ // Uses standard Base64 library but removes padding chars
+ final String EXPECTED_MIME_B64 = Base64.encoder.encodeToString(EXPECTED_BYTES).replaceAll(/=/, '')
+
+ // Act
+ String convertedBase64 = BcryptSecureHasher.convertBcryptRadix64ToMimeBase64(INPUT_RADIX_64)
+ logger.info("Converted (R64) ${INPUT_RADIX_64} to (B64) ${convertedBase64}")
+
+ String convertedRadix64 = BcryptSecureHasher.convertMimeBase64ToBcryptRadix64(convertedBase64)
+ logger.info("Converted (B64) ${convertedBase64} to (R64) ${convertedRadix64}")
+
+ // Assert
+ assert convertedBase64 == EXPECTED_MIME_B64
+ assert convertedRadix64 == INPUT_RADIX_64
+ }
+}
+
diff --git a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/CipherProviderFactoryGroovyTest.groovy b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/CipherProviderFactoryGroovyTest.groovy
index b64a162..d8e2f1d 100644
--- a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/CipherProviderFactoryGroovyTest.groovy
+++ b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/CipherProviderFactoryGroovyTest.groovy
@@ -40,7 +40,8 @@ class CipherProviderFactoryGroovyTest extends GroovyTestCase {
(KeyDerivationFunction.NONE) : AESKeyedCipherProvider.class,
(KeyDerivationFunction.OPENSSL_EVP_BYTES_TO_KEY): OpenSSLPKCS5CipherProvider.class,
(KeyDerivationFunction.PBKDF2) : PBKDF2CipherProvider.class,
- (KeyDerivationFunction.SCRYPT) : ScryptCipherProvider.class
+ (KeyDerivationFunction.SCRYPT) : ScryptCipherProvider.class,
+ (KeyDerivationFunction.ARGON2) : Argon2CipherProvider.class
]
@BeforeClass
@@ -66,9 +67,9 @@ class CipherProviderFactoryGroovyTest extends GroovyTestCase {
// Act
KeyDerivationFunction.values().each { KeyDerivationFunction kdf ->
- logger.info("Expected: ${kdf.name} -> ${EXPECTED_CIPHER_PROVIDERS.get(kdf).simpleName}")
+ logger.info("Expected: ${kdf.kdfName} -> ${EXPECTED_CIPHER_PROVIDERS.get(kdf).simpleName}")
CipherProvider cp = CipherProviderFactory.getCipherProvider(kdf)
- logger.info("Resolved: ${kdf.name} -> ${cp.class.simpleName}")
+ logger.info("Resolved: ${kdf.kdfName} -> ${cp.class.simpleName}")
// Assert
assert cp.class == (EXPECTED_CIPHER_PROVIDERS.get(kdf))
@@ -82,16 +83,16 @@ class CipherProviderFactoryGroovyTest extends GroovyTestCase {
// 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")
+ logger.info("Expected: ${invalidKDF.kdfName} -> error")
// Act
def msg = shouldFail(IllegalArgumentException) {
CipherProvider cp = CipherProviderFactory.getCipherProvider(invalidKDF)
- logger.info("Resolved: ${invalidKDF.name} -> ${cp.class.simpleName}")
+ logger.info("Resolved: ${invalidKDF.kdfName} -> ${cp.class.simpleName}")
}
logger.expected(msg)
// Assert
- assert msg =~ "No cipher provider registered for ${invalidKDF.name}"
+ assert msg =~ "No cipher provider registered for ${invalidKDF.kdfName}"
}
}
diff --git a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/CipherUtilityGroovyTest.groovy b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/CipherUtilityGroovyTest.groovy
index 8f092f3..424b810 100644
--- a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/CipherUtilityGroovyTest.groovy
+++ b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/CipherUtilityGroovyTest.groovy
@@ -16,7 +16,9 @@
*/
package org.apache.nifi.security.util.crypto
+import org.apache.commons.codec.binary.Hex
import org.apache.nifi.security.util.EncryptionMethod
+import org.apache.nifi.security.util.KeyDerivationFunction
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.junit.After
import org.junit.Before
@@ -73,7 +75,7 @@ class CipherUtilityGroovyTest extends GroovyTestCase {
@BeforeClass
static void setUpOnce() {
- Security.addProvider(new BouncyCastleProvider());
+ Security.addProvider(new BouncyCastleProvider())
// Fix because TRIPLEDES -> DESede
def tripleDESAlgorithms = ALGORITHMS_MAPPED_BY_CIPHER.remove("TRIPLEDES")
@@ -248,4 +250,63 @@ class CipherUtilityGroovyTest extends GroovyTestCase {
assert validKeySizes == EXPECTED_KEY_SIZES
}
}
+
+ @Test
+ void testShouldFindSequence() {
+ // Arrange
+ byte[] license = """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
+""".bytes
+
+ byte[] apache = "Apache".bytes
+ byte[] software = "Software".bytes
+ byte[] asf = "ASF".bytes
+ byte[] kafka = "Kafka".bytes
+
+ // Act
+ int apacheIndex = CipherUtility.findSequence(license, apache)
+ logger.info("Looking for ${Hex.encodeHexString(apache)}; found at ${apacheIndex}")
+
+ int softwareIndex = CipherUtility.findSequence(license, software)
+ logger.info("Looking for ${Hex.encodeHexString(software)}; found at ${softwareIndex}")
+
+ int asfIndex = CipherUtility.findSequence(license, asf)
+ logger.info("Looking for ${Hex.encodeHexString(asf)}; found at ${asfIndex}")
+
+ int kafkaIndex = CipherUtility.findSequence(license, kafka)
+ logger.info("Looking for ${Hex.encodeHexString(kafka)}; found at ${kafkaIndex}")
+
+ // Assert
+ assert apacheIndex == 16
+ assert softwareIndex == 23
+ assert asfIndex == 44
+ assert kafkaIndex == -1
+ }
+
+ @Test
+ void testShouldExtractRawSalt() {
+ // Arrange
+ byte[] PLAIN_SALT = [0xab] * 16
+
+ String ARGON2_SALT = Argon2CipherProvider.formSalt(PLAIN_SALT, 8, 1, 1)
+ String BCRYPT_SALT = BcryptCipherProvider.formatSaltForBcrypt(PLAIN_SALT, 10)
+ String SCRYPT_SALT = ScryptCipherProvider.formatSaltForScrypt(PLAIN_SALT, 10, 1, 1)
+
+ // Act
+ def results = KeyDerivationFunction.values().findAll { !it.isStrongKDF() }.collectEntries { KeyDerivationFunction weakKdf ->
+ [weakKdf, CipherUtility.extractRawSalt(PLAIN_SALT, weakKdf)]
+ }
+
+ results.put(KeyDerivationFunction.ARGON2, CipherUtility.extractRawSalt(ARGON2_SALT.bytes, KeyDerivationFunction.ARGON2))
+ results.put(KeyDerivationFunction.BCRYPT, CipherUtility.extractRawSalt(BCRYPT_SALT.bytes, KeyDerivationFunction.BCRYPT))
+ results.put(KeyDerivationFunction.SCRYPT, CipherUtility.extractRawSalt(SCRYPT_SALT.bytes, KeyDerivationFunction.SCRYPT))
+ results.put(KeyDerivationFunction.PBKDF2, CipherUtility.extractRawSalt(PLAIN_SALT, KeyDerivationFunction.PBKDF2))
+
+ // Assert
+ assert results.every { k, v -> v == PLAIN_SALT }
+ }
}
diff --git a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/NiFiLegacyCipherProviderGroovyTest.groovy b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/NiFiLegacyCipherProviderGroovyTest.groovy
index 70db6a7..70ecda0 100644
--- a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/NiFiLegacyCipherProviderGroovyTest.groovy
+++ b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/NiFiLegacyCipherProviderGroovyTest.groovy
@@ -265,7 +265,7 @@ class NiFiLegacyCipherProviderGroovyTest {
void testShouldDetermineDependenceOnUnlimitedStrengthCrypto() throws IOException {
def encryptionMethods = EncryptionMethod.values().findAll { it.algorithm.startsWith("PBE") }
- boolean unlimitedCryptoSupported = PasswordBasedEncryptor.supportsUnlimitedStrength()
+ boolean unlimitedCryptoSupported = CipherUtility.isUnlimitedStrengthCryptoSupported()
logger.info("This JVM supports unlimited strength crypto: ${unlimitedCryptoSupported}")
def longestSupportedPasswordByEM = [:]
diff --git a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/PBKDF2CipherProviderGroovyTest.groovy b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/PBKDF2CipherProviderGroovyTest.groovy
index 661b809..795f3dc 100644
--- a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/PBKDF2CipherProviderGroovyTest.groovy
+++ b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/PBKDF2CipherProviderGroovyTest.groovy
@@ -19,12 +19,7 @@ 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.*
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import org.slf4j.Logger
@@ -334,6 +329,39 @@ class PBKDF2CipherProviderGroovyTest {
}
@Test
+ void testGetCipherShouldHandleDifferentPRFs() throws Exception {
+ // Arrange
+ RandomIVPBECipherProvider sha256CP = new PBKDF2CipherProvider("SHA-256", TEST_ITERATION_COUNT)
+ RandomIVPBECipherProvider sha512CP = new PBKDF2CipherProvider("SHA-512", TEST_ITERATION_COUNT)
+
+ final String PLAINTEXT = "This is a plaintext message."
+ final String PASSWORD = "thisIsABadPassword"
+ final byte[] SALT = [0x11] * 16
+ final byte[] IV = [0x22] * 16
+
+ EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
+ logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()}")
+
+ // Act
+ Cipher sha256Cipher = sha256CP.getCipher(encryptionMethod, PASSWORD, SALT, IV, DEFAULT_KEY_LENGTH, true)
+ byte[] sha256CipherBytes = sha256Cipher.doFinal(PLAINTEXT.bytes)
+
+ Cipher sha512Cipher = sha512CP.getCipher(encryptionMethod, PASSWORD, SALT, IV, DEFAULT_KEY_LENGTH, true)
+ byte[] sha512CipherBytes = sha512Cipher.doFinal(PLAINTEXT.bytes)
+
+ // Assert
+ assert sha512CipherBytes != sha256CipherBytes
+
+ Cipher sha256DecryptCipher = sha256CP.getCipher(encryptionMethod, PASSWORD, SALT, IV, DEFAULT_KEY_LENGTH, false)
+ byte[] sha256RecoveredBytes = sha256DecryptCipher.doFinal(sha256CipherBytes)
+ assert sha256RecoveredBytes == PLAINTEXT.bytes
+
+ Cipher sha512DecryptCipher = sha512CP.getCipher(encryptionMethod, PASSWORD, SALT, IV, DEFAULT_KEY_LENGTH, false)
+ byte[] sha512RecoveredBytes = sha512DecryptCipher.doFinal(sha512CipherBytes)
+ assert sha512RecoveredBytes == PLAINTEXT.bytes
+ }
+
+ @Test
void testGetCipherForDecryptShouldRequireIV() throws Exception {
// Arrange
RandomIVPBECipherProvider cipherProvider = new PBKDF2CipherProvider(DEFAULT_PRF, TEST_ITERATION_COUNT)
diff --git a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/PBKDF2SecureHasherTest.groovy b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/PBKDF2SecureHasherTest.groovy
new file mode 100644
index 0000000..2e5b10b
--- /dev/null
+++ b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/PBKDF2SecureHasherTest.groovy
@@ -0,0 +1,417 @@
+/*
+ * 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.kerby.util.Hex
+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 java.nio.charset.StandardCharsets
+import java.security.Security
+
+@RunWith(JUnit4.class)
+class PBKDF2SecureHasherTest extends GroovyTestCase {
+ private static final Logger logger = LoggerFactory.getLogger(PBKDF2SecureHasherTest)
+
+ @BeforeClass
+ static void setupOnce() throws Exception {
+ Security.addProvider(new BouncyCastleProvider())
+
+ logger.metaClass.methodMissing = { String name, args ->
+ logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}")
+ }
+ }
+
+ @Before
+ void setUp() throws Exception {
+ }
+
+ @After
+ void tearDown() throws Exception {
+ }
+
+ @Test
+ void testShouldBeDeterministicWithStaticSalt() {
+ // Arrange
+ int cost = 10_000
+ int dkLength = 32
+ logger.info("Generating PBKDF2 hash for iterations: ${cost}, desired key length: ${dkLength} bytes (${dkLength * 8} bits)")
+
+ int testIterations = 10
+ byte[] inputBytes = "This is a sensitive value".bytes
+
+ final String EXPECTED_HASH_HEX = "2c47a6d801b71e087f94792079c40880aea29013bfffd0ab94b1bc112ea52511"
+
+ PBKDF2SecureHasher pbkdf2SecureHasher = new PBKDF2SecureHasher(cost, dkLength)
+
+ def results = []
+
+ // Act
+ testIterations.times { int i ->
+ byte[] hash = pbkdf2SecureHasher.hashRaw(inputBytes)
+ String hashHex = Hex.encode(hash)
+ logger.info("Generated hash: ${hashHex}")
+ results << hashHex
+ }
+
+ // Assert
+ assert results.every { it == EXPECTED_HASH_HEX }
+ }
+
+ @Test
+ void testShouldBeDifferentWithRandomSalt() {
+ // Arrange
+ String prf = "SHA512"
+ int cost = 10_000
+ int saltLength = 16
+ int dkLength = 32
+ logger.info("Generating PBKDF2 hash for prf: ${prf}, iterations: ${cost}, salt length: ${saltLength} bytes, desired key length: ${dkLength} bytes (${dkLength * 8} bits)")
+
+ int testIterations = 10
+ byte[] inputBytes = "This is a sensitive value".bytes
+
+ final String EXPECTED_HASH_HEX = "2c47a6d801b71e087f94792079c40880aea29013bfffd0ab94b1bc112ea52511"
+
+ PBKDF2SecureHasher pbkdf2SecureHasher = new PBKDF2SecureHasher(prf, cost, saltLength, dkLength)
+
+ def results = []
+
+ // Act
+ testIterations.times { int i ->
+ byte[] hash = pbkdf2SecureHasher.hashRaw(inputBytes)
+ String hashHex = Hex.encode(hash)
+ logger.info("Generated hash: ${hashHex}")
+ results << hashHex
+ }
+
+ // Assert
+ assert results.unique().size() == results.size()
+ assert results.every { it != EXPECTED_HASH_HEX }
+ }
+
+ @Test
+ void testShouldHandleArbitrarySalt() {
+ // Arrange
+ String prf = "SHA512"
+ int cost = 10_000
+ int saltLength = 16
+ int dkLength = 32
+ logger.info("Generating PBKDF2 hash for prf: ${prf}, iterations: ${cost}, salt length: ${saltLength} bytes, desired key length: ${dkLength} bytes (${dkLength * 8} bits)")
+
+ def input = "This is a sensitive value"
+ byte[] inputBytes = input.bytes
+
+ final String EXPECTED_HASH_HEX = "2c47a6d801b71e087f94792079c40880aea29013bfffd0ab94b1bc112ea52511"
+ final String EXPECTED_HASH_BASE64 = "LEem2AG3Hgh/lHkgecQIgK6ikBO//9CrlLG8ES6lJRE"
+ final byte[] EXPECTED_HASH_BYTES = Hex.decode(EXPECTED_HASH_HEX)
+
+ PBKDF2SecureHasher staticSaltHasher = new PBKDF2SecureHasher(cost, dkLength)
+ PBKDF2SecureHasher arbitrarySaltHasher = new PBKDF2SecureHasher(prf, cost, saltLength, dkLength)
+
+ final byte[] STATIC_SALT = AbstractSecureHasher.STATIC_SALT
+ final String DIFFERENT_STATIC_SALT = "Diff Static Salt"
+
+ // Act
+ byte[] staticSaltHash = staticSaltHasher.hashRaw(inputBytes)
+ byte[] arbitrarySaltHash = arbitrarySaltHasher.hashRaw(inputBytes, STATIC_SALT)
+ byte[] differentArbitrarySaltHash = arbitrarySaltHasher.hashRaw(inputBytes, DIFFERENT_STATIC_SALT.getBytes(StandardCharsets.UTF_8))
+ byte[] differentSaltHash = arbitrarySaltHasher.hashRaw(inputBytes)
+
+ String staticSaltHashHex = staticSaltHasher.hashHex(input)
+ String arbitrarySaltHashHex = arbitrarySaltHasher.hashHex(input, new String(STATIC_SALT, StandardCharsets.UTF_8))
+ String differentArbitrarySaltHashHex = arbitrarySaltHasher.hashHex(input, DIFFERENT_STATIC_SALT)
+ String differentSaltHashHex = arbitrarySaltHasher.hashHex(input)
+
+ String staticSaltHashBase64 = staticSaltHasher.hashBase64(input)
+ String arbitrarySaltHashBase64 = arbitrarySaltHasher.hashBase64(input, new String(STATIC_SALT, StandardCharsets.UTF_8))
+ String differentArbitrarySaltHashBase64 = arbitrarySaltHasher.hashBase64(input, DIFFERENT_STATIC_SALT)
+ String differentSaltHashBase64 = arbitrarySaltHasher.hashBase64(input)
+
+ // Assert
+ assert staticSaltHash == EXPECTED_HASH_BYTES
+ assert arbitrarySaltHash == EXPECTED_HASH_BYTES
+ assert differentArbitrarySaltHash != EXPECTED_HASH_BYTES
+ assert differentSaltHash != EXPECTED_HASH_BYTES
+
+ assert staticSaltHashHex == EXPECTED_HASH_HEX
+ assert arbitrarySaltHashHex == EXPECTED_HASH_HEX
+ assert differentArbitrarySaltHashHex != EXPECTED_HASH_HEX
+ assert differentSaltHashHex != EXPECTED_HASH_HEX
+
+ assert staticSaltHashBase64 == EXPECTED_HASH_BASE64
+ assert arbitrarySaltHashBase64 == EXPECTED_HASH_BASE64
+ assert differentArbitrarySaltHashBase64 != EXPECTED_HASH_BASE64
+ assert differentSaltHashBase64 != EXPECTED_HASH_BASE64
+ }
+
+ @Test
+ void testShouldValidateArbitrarySalt() {
+ // Assert
+ String prf = "SHA512"
+ int cost = 10_000
+ int saltLength = 16
+ int dkLength = 32
+ logger.info("Generating PBKDF2 hash for prf: ${prf}, iterations: ${cost}, salt length: ${saltLength} bytes, desired key length: ${dkLength} bytes (${dkLength * 8} bits)")
+
+ def input = "This is a sensitive value"
+ byte[] inputBytes = input.bytes
+
+ // Static salt instance
+ PBKDF2SecureHasher secureHasher = new PBKDF2SecureHasher(prf, cost, saltLength, dkLength)
+ byte[] STATIC_SALT = "bad_sal".bytes
+
+ // Act
+ def initializeMsg = shouldFail(IllegalArgumentException) {
+ PBKDF2SecureHasher invalidSaltLengthHasher = new PBKDF2SecureHasher(prf, cost, 7, dkLength)
+ }
+ logger.expected(initializeMsg)
+
+ def arbitrarySaltRawMsg = shouldFail {
+ byte[] arbitrarySaltRaw = secureHasher.hashRaw(inputBytes, STATIC_SALT)
+ }
+
+ def arbitrarySaltHexMsg = shouldFail {
+ byte[] arbitrarySaltHex = secureHasher.hashHex(input, new String(STATIC_SALT, StandardCharsets.UTF_8))
+ }
+
+ def arbitrarySaltBase64Msg = shouldFail {
+ byte[] arbitrarySaltBase64 = secureHasher.hashBase64(input, new String(STATIC_SALT, StandardCharsets.UTF_8))
+ }
+
+ def results = [arbitrarySaltRawMsg, arbitrarySaltHexMsg, arbitrarySaltBase64Msg]
+
+ // Assert
+ assert results.every { it =~ /The salt length \(7 bytes\) is invalid/ }
+ }
+
+ @Test
+ void testShouldFormatHex() {
+ // Arrange
+ String input = "This is a sensitive value"
+
+ final String EXPECTED_HASH_HEX = "8f67110e87d225366e2d79ad251d2cf48f8cb15845800452e0e2cff09f95ef1c"
+
+ PBKDF2SecureHasher pbkdf2SecureHasher = new PBKDF2SecureHasher()
+
+ // Act
+ String hashHex = pbkdf2SecureHasher.hashHex(input)
+ logger.info("Generated hash: ${hashHex}")
+
+ // Assert
+ assert hashHex == EXPECTED_HASH_HEX
+ }
+
+ @Test
+ void testShouldFormatBase64() {
+ // Arrange
+ String input = "This is a sensitive value"
+
+ final String EXPECTED_HASH_BASE64 = "j2cRDofSJTZuLXmtJR0s9I+MsVhFgARS4OLP8J+V7xw"
+
+ PBKDF2SecureHasher pbkdf2SecureHasher = new PBKDF2SecureHasher()
+
+ // Act
+ String hashB64 = pbkdf2SecureHasher.hashBase64(input)
+ logger.info("Generated hash: ${hashB64}")
+
+ // Assert
+ assert hashB64 == EXPECTED_HASH_BASE64
+ }
+
+ @Test
+ void testShouldHandleNullInput() {
+ // Arrange
+ List<String> inputs = [null, ""]
+
+ final String EXPECTED_HASH_HEX = "7f2d8d8c7aaa45471f6c05a8edfe0a3f75fe01478cc965c5dce664e2ac6f5d0a"
+ final String EXPECTED_HASH_BASE64 = "fy2NjHqqRUcfbAWo7f4KP3X+AUeMyWXF3OZk4qxvXQo"
+
+ PBKDF2SecureHasher pbkdf2SecureHasher = new PBKDF2SecureHasher()
+
+ def hexResults = []
+ def B64Results = []
+
+ // Act
+ inputs.each { String input ->
+ String hashHex = pbkdf2SecureHasher.hashHex(input)
+ logger.info("Generated hex-encoded hash: ${hashHex}")
+ hexResults << hashHex
+
+ String hashB64 = pbkdf2SecureHasher.hashBase64(input)
+ logger.info("Generated B64-encoded hash: ${hashB64}")
+ B64Results << hashB64
+ }
+
+ // Assert
+ assert hexResults.every { it == EXPECTED_HASH_HEX }
+ assert B64Results.every { it == EXPECTED_HASH_BASE64 }
+ }
+
+ /**
+ * This test can have the minimum time threshold updated to determine if the performance
+ * is still sufficient compared to the existing threat model.
+ */
+ @Ignore("Long running test")
+ @Test
+ void testDefaultCostParamsShouldBeSufficient() {
+ // Arrange
+ int testIterations = 100
+ byte[] inputBytes = "This is a sensitive value".bytes
+
+ PBKDF2SecureHasher pbkdf2SecureHasher = new PBKDF2SecureHasher()
+
+ def results = []
+ def resultDurations = []
+
+ // Act
+ testIterations.times { int i ->
+ long startNanos = System.nanoTime()
+ byte[] hash = pbkdf2SecureHasher.hashRaw(inputBytes)
+ long endNanos = System.nanoTime()
+ long durationNanos = endNanos - startNanos
+
+ String hashHex = Hex.encode(hash)
+ logger.info("Generated hash: ${hashHex} in ${durationNanos} ns")
+
+ results << hashHex
+ resultDurations << durationNanos
+ }
+
+ // Assert
+ final long MIN_DURATION_NANOS = 75_000_000 // 75 ms
+ assert resultDurations.min() > MIN_DURATION_NANOS
+ assert resultDurations.sum() / testIterations > MIN_DURATION_NANOS
+ }
+
+ @Test
+ void testShouldVerifyIterationCountBoundary() throws Exception {
+ // Arrange
+ def validIterationCounts = [1, 1000, 1_000_000]
+
+ // Act
+ def results = validIterationCounts.collect { int i ->
+ boolean valid = PBKDF2SecureHasher.isIterationCountValid(i)
+ logger.info("Iteration count ${i} is valid: ${valid}")
+ valid
+ }
+
+ // Assert
+ assert results.every()
+ }
+
+ @Test
+ void testShouldFailIterationCountBoundary() throws Exception {
+ // Arrange
+ def invalidIterationCounts = [-1, 0, Integer.MAX_VALUE + 1]
+
+ // Act
+ def results = invalidIterationCounts.collect { i ->
+ boolean valid = PBKDF2SecureHasher.isIterationCountValid(i)
+ logger.info("Iteration count ${i} is valid: ${valid}")
+ valid
+ }
+
+ // Assert
+ results.each { valid ->
+ assert !valid
+ }
+ }
+
+ @Test
+ void testShouldVerifyDKLengthBoundary() throws Exception {
+ // Arrange
+ def validHLengths = [32, 64]
+
+ // 1 and MAX_VALUE are the length boundaries, inclusive
+ def validDKLengths = [1, 1000, 1_000_000, Integer.MAX_VALUE]
+
+ // Act
+ def results = validHLengths.collectEntries { int hLen ->
+ def dkResults = validDKLengths.collect { int dkLength ->
+ boolean valid = PBKDF2SecureHasher.isDKLengthValid(hLen, dkLength)
+ logger.info("Derived key length ${dkLength} bytes (${dkLength * 8} bits) with hLen ${hLen} bytes (${hLen * 8} bits) is valid: ${valid}")
+ valid
+ }
+ [hLen, dkResults]
+ }
+
+ // Assert
+ results.each { int hLen, def dkResults ->
+ assert dkResults.every()
+ }
+ }
+
+ @Test
+ void testShouldFailDKLengthBoundary() throws Exception {
+ // Arrange
+ def validHLengths = [32, 64]
+
+ // MAX_VALUE + 1 will become MIN_VALUE because of signed integer math
+ def invalidDKLengths = [-1, 0, Integer.MAX_VALUE + 1, new Integer(Integer.MAX_VALUE * 2 - 1)]
+
+ // Act
+ def results = validHLengths.collectEntries { int hLen ->
+ def dkResults = invalidDKLengths.collect { int dkLength ->
+ boolean valid = PBKDF2SecureHasher.isDKLengthValid(hLen, dkLength)
+ logger.info("Derived key length ${dkLength} bytes (${dkLength * 8} bits) with hLen ${hLen} bytes (${hLen * 8} bits) is valid: ${valid}")
+ valid
+ }
+ [hLen, dkResults]
+ }
+
+ // Assert
+ results.each { int hLen, def dkResults ->
+ assert dkResults.every { boolean valid -> !valid }
+ }
+ }
+
+ @Test
+ void testShouldVerifySaltLengthBoundary() throws Exception {
+ // Arrange
+ def saltLengths = [0, 16, 64]
+
+ // Act
+ def results = saltLengths.collect { saltLength ->
+ def isValid = new PBKDF2SecureHasher().isSaltLengthValid(saltLength)
+ logger.info("Salt length ${saltLength} bytes is valid: ${isValid}")
+ isValid
+ }
+
+ // Assert
+ assert results.every()
+ }
+
+ @Test
+ void testShouldFailSaltLengthBoundary() throws Exception {
+ // Arrange
+ def saltLengths = [-8, 1, Integer.MAX_VALUE + 1]
+
+ // Act
+ def results = saltLengths.collect { saltLength ->
+ def isValid = new PBKDF2SecureHasher().isSaltLengthValid(saltLength)
+ logger.info("Salt length ${saltLength} bytes is valid: ${isValid}")
+ isValid
+ }
+
+ // Assert
+ results.each { assert !it }
+ }
+}
diff --git a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/ScryptCipherProviderGroovyTest.groovy b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/ScryptCipherProviderGroovyTest.groovy
index 7b912da..d6dd5b3 100644
--- a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/ScryptCipherProviderGroovyTest.groovy
+++ b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/ScryptCipherProviderGroovyTest.groovy
@@ -260,6 +260,7 @@ class ScryptCipherProviderGroovyTest {
final String PASSWORD = "shortPassword"
final byte[] SALT = new byte[cipherProvider.defaultSaltLength]
new SecureRandom().nextBytes(SALT)
+// final byte[] SALT = [0x00] * 16 as byte[]
final String EXPECTED_FORMATTED_SALT = cipherProvider.formatSaltForScrypt(SALT)
logger.info("Expected salt: ${EXPECTED_FORMATTED_SALT}")
@@ -284,6 +285,7 @@ class ScryptCipherProviderGroovyTest {
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)
+ logger.info("Manually derived key bytes: ${Hex.encodeHexString(keyBytes)}")
SecretKey key = new SecretKeySpec(keyBytes, "AES")
Cipher manualCipher = Cipher.getInstance(encryptionMethod.algorithm, encryptionMethod.provider)
manualCipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(iv))
@@ -300,8 +302,8 @@ class ScryptCipherProviderGroovyTest {
// 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"
+ final def INVALID_SALTS = ['bad_sal', '$3a$11$', 'x', '$2a$10$']
+ final LENGTH_MESSAGE = "The raw salt must be greater than or equal to 8 bytes"
EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
logger.info("Using algorithm: ${encryptionMethod.getAlgorithm()}")
@@ -398,7 +400,6 @@ class ScryptCipherProviderGroovyTest {
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
@@ -516,8 +517,8 @@ class ScryptCipherProviderGroovyTest {
@Test
void testShouldVerifyPBoundary() throws Exception {
// Arrange
- final int r = 8;
- final int p = 1;
+ final int r = 8
+ final int p = 1
// Act
boolean valid = ScryptCipherProvider.isPValid(r, p)
@@ -549,7 +550,7 @@ class ScryptCipherProviderGroovyTest {
@Test
void testShouldVerifyRValue() throws Exception {
// Arrange
- final int r = 8;
+ final int r = 8
// Act
boolean valid = ScryptCipherProvider.isRValid(r)
@@ -561,7 +562,7 @@ class ScryptCipherProviderGroovyTest {
@Test
void testShouldFailRValue() throws Exception {
// Arrange
- final int r = 0;
+ final int r = 0
// Act
boolean valid = ScryptCipherProvider.isRValid(r)
@@ -573,9 +574,9 @@ class ScryptCipherProviderGroovyTest {
@Test
void testShouldValidateScryptCipherProviderPBoundary() throws Exception {
// Arrange
- final int n = 64;
- final int r = 8;
- final int p = 1;
+ final int n = 64
+ final int r = 8
+ final int p = 1
// Act
ScryptCipherProvider testCipherProvider = new ScryptCipherProvider(n, r, p)
@@ -587,9 +588,9 @@ class ScryptCipherProviderGroovyTest {
@Test
void testShouldCatchInvalidP() throws Exception {
// Arrange
- final int n = 64;
- final int r = 8;
- final int p = 0;
+ final int n = 64
+ final int r = 8
+ final int p = 0
// Act
def msg = shouldFail(IllegalArgumentException) {
@@ -604,9 +605,9 @@ class ScryptCipherProviderGroovyTest {
@Test
void testShouldCatchInvalidR() throws Exception {
// Arrange
- final int n = 64;
- final int r = 0;
- final int p = 0;
+ final int n = 64
+ final int r = 0
+ final int p = 0
// Act
def msg = shouldFail(IllegalArgumentException) {
@@ -618,6 +619,19 @@ class ScryptCipherProviderGroovyTest {
assert msg =~ "Invalid r value; must be greater than 0"
}
+ @Test
+ void testShouldAcceptFormattedSaltWithPlus() throws Exception {
+ // Arrange
+ final String FULL_SALT_WITH_PLUS = "\$s0\$e0801\$smJD8vwWI3+uQCHYz2yg0+"
+
+ // Act
+ boolean isScryptSalt = ScryptCipherProvider.isScryptFormattedSalt(FULL_SALT_WITH_PLUS)
+ logger.info("Is Scrypt salt: ${isScryptSalt}")
+
+ // Assert
+ assert isScryptSalt
+ }
+
@Ignore("This test can be run on a specific machine to evaluate if the default parameters are sufficient")
@Test
void testDefaultConstructorShouldProvideStrongParameters() {
diff --git a/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/ScryptSecureHasherTest.groovy b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/ScryptSecureHasherTest.groovy
new file mode 100644
index 0000000..46b0249
--- /dev/null
+++ b/nifi-commons/nifi-security-utils/src/test/groovy/org/apache/nifi/security/util/crypto/ScryptSecureHasherTest.groovy
@@ -0,0 +1,485 @@
+/*
+ * 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.kerby.util.Hex
+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 java.nio.charset.StandardCharsets
+import java.security.Security
+
+@RunWith(JUnit4.class)
+class ScryptSecureHasherTest extends GroovyTestCase {
+ private static final Logger logger = LoggerFactory.getLogger(ScryptSecureHasherTest)
+
+ @BeforeClass
+ static void setupOnce() throws Exception {
+ Security.addProvider(new BouncyCastleProvider())
+
+ logger.metaClass.methodMissing = { String name, args ->
+ logger.info("[${name?.toUpperCase()}] ${(args as List).join(" ")}")
+ }
+ }
+
+ @Before
+ void setUp() throws Exception {
+ }
+
+ @After
+ void tearDown() throws Exception {
+ }
+
+ private static byte[] decodeHex(String hex) {
+ Hex.decode(hex?.replaceAll("[^0-9a-fA-F]", ""))
+ }
+
+ @Test
+ void testShouldBeDeterministicWithStaticSalt() {
+ // Arrange
+ int n = 1024
+ int r = 8
+ int p = 2
+ int dkLength = 32
+ logger.info("Generating Scrypt hash for iterations: ${n}, mem: ${r} B, parallelism: ${p}, desired key length: ${dkLength}")
+
+ int testIterations = 10
+ byte[] inputBytes = "This is a sensitive value".bytes
+
+ final String EXPECTED_HASH_HEX = "a67fd2f4b3aa577b8ecdb682e60b4451a84611dcbbc534bce17616056ef8965d"
+
+ ScryptSecureHasher scryptSH = new ScryptSecureHasher(n, r, p, dkLength)
+
+ def results = []
+
+ // Act
+ testIterations.times { int i ->
+ byte[] hash = scryptSH.hashRaw(inputBytes)
+ String hashHex = Hex.encode(hash)
+ logger.info("Generated hash: ${hashHex}")
+ results << hashHex
+ }
+
+ // Assert
+ assert results.every { it == EXPECTED_HASH_HEX }
+ }
+
+ @Test
+ void testShouldBeDifferentWithRandomSalt() {
+ // Arrange
+ int n = 1024
+ int r = 8
+ int p = 2
+ int dkLength = 128
+ logger.info("Generating Scrypt hash for iterations: ${n}, mem: ${r} B, parallelism: ${p}, desired key length: ${dkLength}")
+
+ int testIterations = 10
+ byte[] inputBytes = "This is a sensitive value".bytes
+
+ final String EXPECTED_HASH_HEX = "a67fd2f4b3aa577b8ecdb682e60b4451"
+
+ ScryptSecureHasher scryptSH = new ScryptSecureHasher(n, r, p, dkLength, 16)
+
+ def results = []
+
+ // Act
+ testIterations.times { int i ->
+ byte[] hash = scryptSH.hashRaw(inputBytes)
+ String hashHex = Hex.encode(hash)
+ logger.info("Generated hash: ${hashHex}")
+ results << hashHex
+ }
+
+ // Assert
+ assert results.unique().size() == results.size()
+ assert results.every { it != EXPECTED_HASH_HEX }
+ }
+
+ @Test
+ void testShouldHandleArbitrarySalt() {
+ // Arrange
+ int n = 1024
+ int r = 8
+ int p = 2
+ int dkLength = 32
+ logger.info("Generating Scrypt hash for iterations: ${n}, mem: ${r} B, parallelism: ${p}, desired key length: ${dkLength}")
+
+ def input = "This is a sensitive value"
+ byte[] inputBytes = input.bytes
+
+ final String EXPECTED_HASH_HEX = "a67fd2f4b3aa577b8ecdb682e60b4451a84611dcbbc534bce17616056ef8965d"
+ final String EXPECTED_HASH_BASE64 = "pn/S9LOqV3uOzbaC5gtEUahGEdy7xTS84XYWBW74ll0"
+ final byte[] EXPECTED_HASH_BYTES = Hex.decode(EXPECTED_HASH_HEX)
+
+ // Static salt instance
+ ScryptSecureHasher staticSaltHasher = new ScryptSecureHasher(n, r, p, dkLength)
+ ScryptSecureHasher arbitrarySaltHasher = new ScryptSecureHasher(n, r, p, dkLength, 16)
+
+ final byte[] STATIC_SALT = AbstractSecureHasher.STATIC_SALT
+ final String DIFFERENT_STATIC_SALT = "Diff Static Salt"
+
+ // Act
+ byte[] staticSaltHash = staticSaltHasher.hashRaw(inputBytes)
+ byte[] arbitrarySaltHash = arbitrarySaltHasher.hashRaw(inputBytes, STATIC_SALT)
+ byte[] differentArbitrarySaltHash = arbitrarySaltHasher.hashRaw(inputBytes, DIFFERENT_STATIC_SALT.getBytes(StandardCharsets.UTF_8))
+ byte[] differentSaltHash = arbitrarySaltHasher.hashRaw(inputBytes)
+
+ String staticSaltHashHex = staticSaltHasher.hashHex(input)
+ String arbitrarySaltHashHex = arbitrarySaltHasher.hashHex(input, new String(STATIC_SALT, StandardCharsets.UTF_8))
+ String differentArbitrarySaltHashHex = arbitrarySaltHasher.hashHex(input, DIFFERENT_STATIC_SALT)
+ String differentSaltHashHex = arbitrarySaltHasher.hashHex(input)
+
+ String staticSaltHashBase64 = staticSaltHasher.hashBase64(input)
+ String arbitrarySaltHashBase64 = arbitrarySaltHasher.hashBase64(input, new String(STATIC_SALT, StandardCharsets.UTF_8))
+ String differentArbitrarySaltHashBase64 = arbitrarySaltHasher.hashBase64(input, DIFFERENT_STATIC_SALT)
+ String differentSaltHashBase64 = arbitrarySaltHasher.hashBase64(input)
+
+ // Assert
+ assert staticSaltHash == EXPECTED_HASH_BYTES
+ assert arbitrarySaltHash == EXPECTED_HASH_BYTES
+ assert differentArbitrarySaltHash != EXPECTED_HASH_BYTES
+ assert differentSaltHash != EXPECTED_HASH_BYTES
+
+ assert staticSaltHashHex == EXPECTED_HASH_HEX
+ assert arbitrarySaltHashHex == EXPECTED_HASH_HEX
+ assert differentArbitrarySaltHashHex != EXPECTED_HASH_HEX
+ assert differentSaltHashHex != EXPECTED_HASH_HEX
+
+ assert staticSaltHashBase64 == EXPECTED_HASH_BASE64
+ assert arbitrarySaltHashBase64 == EXPECTED_HASH_BASE64
+ assert differentArbitrarySaltHashBase64 != EXPECTED_HASH_BASE64
+ assert differentSaltHashBase64 != EXPECTED_HASH_BASE64
+ }
+
+ @Test
+ void testShouldValidateArbitrarySalt() {
+ // Arrange
+ int n = 1024
+ int r = 8
+ int p = 2
+ int dkLength = 32
+ logger.info("Generating Scrypt hash for iterations: ${n}, mem: ${r} B, parallelism: ${p}, desired key length: ${dkLength}")
+
+ def input = "This is a sensitive value"
+ byte[] inputBytes = input.bytes
+
+ final String EXPECTED_HASH_HEX = "a67fd2f4b3aa577b8ecdb682e60b4451a84611dcbbc534bce17616056ef8965d"
+ final String EXPECTED_HASH_BASE64 = "pn/S9LOqV3uOzbaC5gtEUahGEdy7xTS84XYWBW74ll0="
+ final byte[] EXPECTED_HASH_BYTES = Hex.decode(EXPECTED_HASH_HEX)
+
+ // Static salt instance
+ ScryptSecureHasher secureHasher = new ScryptSecureHasher(n, r, p, dkLength, 16)
+ final byte[] STATIC_SALT = "bad_sal".bytes
+
+ // Act
+ def initializationMsg = shouldFail(IllegalArgumentException) {
+ ScryptSecureHasher invalidSaltLengthHasher = new ScryptSecureHasher(n, r, p, dkLength, 7)
+ }
+ logger.expected(initializationMsg)
+
+ def arbitrarySaltRawMsg = shouldFail {
+ byte[] arbitrarySaltHash = secureHasher.hashRaw(inputBytes, STATIC_SALT)
+ }
+
+ def arbitrarySaltHexMsg = shouldFail {
+ String arbitrarySaltHashHex = secureHasher.hashHex(input, new String(STATIC_SALT, StandardCharsets.UTF_8))
+ }
+
+ def arbitrarySaltB64Msg = shouldFail {
+ String arbitrarySaltHashBase64 = secureHasher.hashBase64(input, new String(STATIC_SALT, StandardCharsets.UTF_8))
+ }
+
+ def results = [arbitrarySaltRawMsg, arbitrarySaltHexMsg, arbitrarySaltB64Msg]
+
+ // Assert
+ assert results.every { it =~ /The salt length \(7 bytes\) is invalid/ }
+ }
+
+ @Test
+ void testShouldFormatHex() {
+ // Arrange
+ String input = "This is a sensitive value"
+
+ final String EXPECTED_HASH_HEX = "6a9c827815fe0718af5e336811fc78dd719c8d9505e015283239b9bf1d24ee71"
+
+ SecureHasher scryptSH = new ScryptSecureHasher()
+
+ // Act
+ String hashHex = scryptSH.hashHex(input)
+ logger.info("Generated hash: ${hashHex}")
+
+ // Assert
+ assert hashHex == EXPECTED_HASH_HEX
+ }
+
+ @Test
+ void testShouldFormatBase64() {
+ // Arrange
+ String input = "This is a sensitive value"
+
+ final String EXPECTED_HASH_BASE64 = "apyCeBX+BxivXjNoEfx43XGcjZUF4BUoMjm5vx0k7nE"
+
+ SecureHasher scryptSH = new ScryptSecureHasher()
+
+ // Act
+ String hashB64 = scryptSH.hashBase64(input)
+ logger.info("Generated hash: ${hashB64}")
+
+ // Assert
+ assert hashB64 == EXPECTED_HASH_BASE64
+ }
+
+ @Test
+ void testShouldHandleNullInput() {
+ // Arrange
+ List<String> inputs = [null, ""]
+
+ final String EXPECTED_HASH_HEX = ""
+ final String EXPECTED_HASH_BASE64 = ""
+
+ ScryptSecureHasher scryptSH = new ScryptSecureHasher()
+
+ def hexResults = []
+ def B64Results = []
+
+ // Act
+ inputs.each { String input ->
+ String hashHex = scryptSH.hashHex(input)
+ logger.info("Generated hex-encoded hash: ${hashHex}")
+ hexResults << hashHex
+
+ String hashB64 = scryptSH.hashBase64(input)
+ logger.info("Generated B64-encoded hash: ${hashB64}")
+ B64Results << hashB64
+ }
+
+ // Assert
+ assert hexResults.every { it == EXPECTED_HASH_HEX }
+ assert B64Results.every { it == EXPECTED_HASH_BASE64 }
+ }
+
+ /**
+ * This test can have the minimum time threshold updated to determine if the performance
+ * is still sufficient compared to the existing threat model.
+ */
+ @Ignore("Long running test")
+ @Test
+ void testDefaultCostParamsShouldBeSufficient() {
+ // Arrange
+ int testIterations = 100
+ byte[] inputBytes = "This is a sensitive value".bytes
+
+ ScryptSecureHasher scryptSH = new ScryptSecureHasher()
+
+ def results = []
+ def resultDurations = []
+
+ // Act
+ testIterations.times { int i ->
+ long startNanos = System.nanoTime()
+ byte[] hash = scryptSH.hashRaw(inputBytes)
+ long endNanos = System.nanoTime()
+ long durationNanos = endNanos - startNanos
+
+ String hashHex = Hex.encode(hash)
+ logger.info("Generated hash: ${hashHex} in ${durationNanos} ns")
+
+ results << hashHex
+ resultDurations << durationNanos
+ }
+
+ // Assert
+ final long MIN_DURATION_NANOS = 75_000_000 // 75 ms
+ assert resultDurations.min() > MIN_DURATION_NANOS
+ assert resultDurations.sum() / testIterations > MIN_DURATION_NANOS
+ }
+
+ @Test
+ void testShouldVerifyRBoundary() throws Exception {
+ // Arrange
+ final int r = 32
+
+ // Act
+ boolean valid = ScryptSecureHasher.isRValid(r)
+
+ // Assert
+ assert valid
+ }
+
+ @Test
+ void testShouldFailRBoundary() throws Exception {
+ // Arrange
+ def rValues = [-8, 0, 2147483647]
+
+ // Act
+ def results = rValues.collect { rValue ->
+ def isValid = ScryptSecureHasher.isRValid(rValue)
+ [rValue, isValid]
+ }
+
+ // Assert
+ results.each { rValue, isRValid ->
+ logger.info("For R value ${rValue}, R is ${isRValid ? "valid" : "invalid"}")
+ assert !isRValid
+ }
+ }
+
+ @Test
+ void testShouldVerifyNBoundary() throws Exception {
+ // Arrange
+ final Integer n = 16385
+ final int r = 8
+
+ // Act
+ boolean valid = ScryptSecureHasher.isNValid(n, r)
+
+ // Assert
+ assert valid
+ }
+
+ @Test
+ void testShouldFailNBoundary() throws Exception {
+ // Arrange
+ Map costParameters = [(-8): 8, 0: 32]
+
+ // Act
+ def results = costParameters.collect { n, p ->
+ def isValid = ScryptSecureHasher.isNValid(n, p)
+ [n, isValid]
+ }
+
+ // Assert
+ results.each { n, isNValid ->
+ logger.info("For N value ${n}, N is ${isNValid ? "valid" : "invalid"}")
+ assert !isNValid
+ }
+ }
+
+ @Test
+ void testShouldVerifyPBoundary() throws Exception {
+ // Arrange
+ final List<Integer> ps = [1, 8, 1024]
+ final List<Integer> rs = [8, 1024, 4096]
+
+ // Act
+ def pResults = ps.collectEntries { int p ->
+ def rResults = rs.collectEntries { int r ->
+ boolean valid = ScryptSecureHasher.isPValid(p, r)
+ logger.info("p ${p} is valid for r ${r}: ${valid}")
+ [r, valid]
+ }
+ [p, rResults]
+ }
+
+ // Assert
+ pResults.each { p, rResult ->
+ assert rResult.every { r, isValid -> isValid }
+ }
+ }
+
+ @Test
+ void testShouldFailIfPBoundaryExceeded() throws Exception {
+ // Arrange
+ final List<Integer> ps = [4096 * 64, 1024 * 1024]
+ final List<Integer> rs = [4096, 1024 * 1024]
+
+ // Act
+ def pResults = ps.collectEntries { int p ->
+ def rResults = rs.collectEntries { int r ->
+ boolean valid = ScryptSecureHasher.isPValid(p, r)
+ logger.info("p ${p} is valid for r ${r}: ${valid}")
+ [r, valid]
+ }
+ [p, rResults]
+ }
+
+ // Assert
+ pResults.each { p, rResult ->
+ assert rResult.every { r, isValid -> !isValid }
+ }
+ }
+
+ @Test
+ void testShouldVerifyDKLengthBoundary() throws Exception {
+ // Arrange
+ final Integer dkLength = 64
+
+ // Act
+ boolean valid = ScryptSecureHasher.isDKLengthValid(dkLength)
+
+ // Assert
+ assert valid
+ }
+
+ @Test
+ void testShouldFailDKLengthBoundary() throws Exception {
+ // Arrange
+ def dKLengths = [-8, 0, 2147483647]
+
+ // Act
+ def results = dKLengths.collect { dKLength ->
+ def isValid = ScryptSecureHasher.isDKLengthValid(dKLength)
+ [dKLength, isValid]
+ }
+
+ // Assert
+ results.each { dKLength, isDKLengthValid ->
+ logger.info("For Desired Key Length value ${dKLength}, dkLength is ${isDKLengthValid ? "valid" : "invalid"}")
+ assert !isDKLengthValid
+ }
+ }
+
+ @Test
+ void testShouldVerifySaltLengthBoundary() throws Exception {
+ // Arrange
+ def saltLengths = [0, 64]
+
+ // Act
+ def results = saltLengths.collect { saltLength ->
+ def isValid = new ScryptSecureHasher().isSaltLengthValid(saltLength)
+ [saltLength, isValid]
+ }
+
+ // Assert
+ results.each { saltLength, isSaltLengthValid ->
+ assert { it == isSaltLengthValid }
+ }
+ }
+
+ @Test
+ void testShouldFailSaltLengthBoundary() throws Exception {
+ // Arrange
+ def saltLengths = [-8, 1, 2147483647]
+
+ // Act
+ def results = saltLengths.collect { saltLength ->
+ def isValid = new ScryptSecureHasher().isSaltLengthValid(saltLength)
+ [saltLength, isValid]
+ }
+
+ // Assert
+ results.each { saltLength, isSaltLengthValid ->
+ logger.info("For Salt Length value ${saltLength}, saltLength is ${isSaltLengthValid ? "valid" : "invalid"}")
+ assert !isSaltLengthValid
+ }
+ }
+
+}
diff --git a/nifi-commons/nifi-security-utils/src/test/resources/openssl_bcrypt.rb b/nifi-commons/nifi-security-utils/src/test/resources/openssl_argon2.rb
similarity index 52%
copy from nifi-commons/nifi-security-utils/src/test/resources/openssl_bcrypt.rb
copy to nifi-commons/nifi-security-utils/src/test/resources/openssl_argon2.rb
index bcb62ce..003b337 100755
--- a/nifi-commons/nifi-security-utils/src/test/resources/openssl_bcrypt.rb
+++ b/nifi-commons/nifi-security-utils/src/test/resources/openssl_argon2.rb
@@ -18,40 +18,53 @@
require 'openssl'
require 'base64'
-# Run `$ gem install bcrypt` >= 2.1.4
-require 'bcrypt'
+# Run `$ gem install argon2`
+require 'argon2'
def bin_to_hex(s)
s.each_byte.map { |b| b.to_s(16).rjust(2, '0') }.join
end
+def hex_to_bin(s)
+ s.scan(/../).map { |x| x.hex.chr }.join
+end
+
plaintext = "This is a plaintext message."
puts "Plaintext: #{plaintext}"
-cipher = OpenSSL::Cipher.new 'AES-128-CBC'
+cipher = OpenSSL::Cipher.new 'AES-256-CBC'
cipher.encrypt
iv = cipher.random_iv
+# iv = hex_to_bin("00" * 16)
+
password = 'thisIsABadPassword'
puts "Password: #{password} #{password.length}"
-work_factor = 10
-puts "Work factor: #{work_factor}"
-key_len = cipher.key_len
-digest = OpenSSL::Digest::SHA512.new
+# The Argon2 gem does not support variable-length hashes, it uses a fixed 32 byte output
+hashLength = 32
+puts "Hash length: #{hashLength}"
+memory = 8
+puts "Memory: #{memory}"
+parallelism = 1
+puts "Parallelism: #{parallelism}"
+iterations = 3
+puts "Iterations: #{iterations}"
puts ""
-hash = BCrypt::Password.create(password, :cost => work_factor)
-puts "Hash: #{hash}"
-full_salt = hash.salt
-puts "Full Salt: #{full_salt} #{full_salt.length}"
-
-key = (digest.digest hash)[0..key_len - 1]
-salt = Base64.decode64(hash.salt[7..-1])
+hasher = Argon2::Password.new(:t_cost => iterations, :m_cost => memory, :p_parallelism => parallelism)
+full_hash = hasher.create(password)
+b64Salt = full_hash[29..50]
+# Decode b64Salt to binary
+salt = Base64.decode64(b64Salt)
+# The hash is Blake2b of the generated result, with a static hash length of 32B for the gem.
+# We use the first 16B of the 32B output because this is a 128 bit cipher.
+key = Base64.decode64(full_hash[52..-1])[0..hashLength-1]
-puts "Salt: #{bin_to_hex(salt)} #{salt.length}"
-puts " IV: #{bin_to_hex(iv)} #{iv.length}"
-puts " Key: #{bin_to_hex(key)} #{key.length}"
+puts "Full Hash: #{full_hash} #{full_hash.length}"
+puts " Salt: #{bin_to_hex(salt)} #{salt.length}"
+puts " IV: #{bin_to_hex(iv)} #{iv.length}"
+puts " Key: #{bin_to_hex(key)} #{key.length}"
cipher.key = key
# Now encrypt the data:
@@ -59,4 +72,4 @@ cipher.key = key
encrypted = cipher.update plaintext
encrypted << cipher.final
puts "Cipher text length: #{encrypted.length}"
-puts "Cipher text: #{bin_to_hex(encrypted)}"
\ No newline at end of file
+puts "Cipher text: #{bin_to_hex(encrypted)}"
diff --git a/nifi-commons/nifi-security-utils/src/test/resources/openssl_argon2_recovery.rb b/nifi-commons/nifi-security-utils/src/test/resources/openssl_argon2_recovery.rb
new file mode 100644
index 0000000..da26cc0
--- /dev/null
+++ b/nifi-commons/nifi-security-utils/src/test/resources/openssl_argon2_recovery.rb
@@ -0,0 +1,96 @@
+#!/usr/bin/env ruby
+
+# 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.
+
+require 'openssl'
+require 'base64'
+
+# Run `$ gem install argon2`
+require 'argon2'
+
+def bin_to_hex(s)
+ s.each_byte.map { |b| b.to_s(16).rjust(2, '0') }.join
+end
+
+def hex_to_bin(s)
+ s.scan(/../).map { |x| x.hex.chr }.join
+end
+
+# Extracts t and m from m=4096,t=3,p=1 as p is not exposed in the ruby gem
+def format_ruby_cost(java_cost)
+ elements = java_cost.gsub(/\$/, '').split(",").collect { |pair| pair.split("=") }.to_h
+ m = Math.log2(elements["m"].to_i)
+ t = elements["t"].to_i
+
+ {:t => t, :m => m}
+end
+
+# Flowfile content from EncryptContent w/ Argon2 (default cost params) + password: thisIsABadPassword
+ciphertext = hex_to_bin("246172676F6E32696424763D3139246D3D343039362C743D332C703D31247264562B4D39613577696C7A796651334136314C36514E69466953414C541B0D6B57611E7A5406A7F63664BD54514E69466949563A90FA629D0BA1A20545F33788C141469A754D717F8F81E22F8234AD0EC13EA139210D4652D2F448E627A9243C2A1C0D0AFF733793AF3A47A357516BDD940EEA6DB858669E961B234EFA3FE1614715C9".gsub("\s", ""))
+
+salt = ciphertext[0..51]
+salt_delimiter = ciphertext[52..59]
+
+cipher = OpenSSL::Cipher.new 'AES-128-CBC'
+cipher.decrypt
+iv = ciphertext[60..75]
+cipher.iv = iv
+iv_delimiter = ciphertext[76..81]
+
+cipher_bytes = ciphertext[82..-1]
+puts "Cipher bytes: #{bin_to_hex(cipher_bytes)} #{cipher_bytes.length}"
+
+password = 'thisIsABadPassword'
+puts "Password: #{password} #{password.length}"
+key_len = cipher.key_len
+
+puts ""
+
+java_cost = salt[15..29]
+puts "Java cost: #{java_cost}"
+ruby_cost = format_ruby_cost(java_cost)
+puts "Ruby cost: t=#{ruby_cost[:t]}, m=#{ruby_cost[:m]}"
+
+raw_salt_bytes = Base64.decode64(salt[30..51])
+# raw_salt_bytes = Base64.decode64("rdV+M9a5wilzyfQ3A61L6Q")
+puts "Ruby salt: #{bin_to_hex(raw_salt_bytes)}"
+
+# If the gem allowed setting an output length, the Password class would allow providing salt directly
+# through constructor map, but output length is fixed at 32 bytes
+hasher = Argon2::Password.new(:t_cost => ruby_cost[:t], :m_cost => ruby_cost[:m], :salt_do_not_supply => raw_salt_bytes)
+full_hash = hasher.create(password)
+puts "Full Hash: #{full_hash}"
+
+hash_hex = Argon2::Engine.hash_argon2id(password, raw_salt_bytes, ruby_cost[:t], ruby_cost[:m], 16)
+hash_bytes = [hash_hex].pack('H*')
+puts "Hash (hex): #{hash_hex}"
+hash_base64 = Base64.encode64(hash_bytes)
+puts "Hash B64: #{hash_base64}"
+
+puts "Full Salt: #{salt} #{salt.length}"
+
+key = hex_to_bin(hash_hex)
+puts "Salt: #{bin_to_hex(raw_salt_bytes)} #{raw_salt_bytes.length}"
+puts " IV: #{bin_to_hex(iv)} #{iv.length}"
+puts " Key: #{bin_to_hex(key)} #{key.length}"
+cipher.key = key
+
+# Now decrypt the data:
+
+plaintext = cipher.update cipher_bytes
+plaintext << cipher.final
+puts "Plaintext length: #{plaintext.length}"
+puts "Plaintext: #{plaintext}"
\ No newline at end of file
diff --git a/nifi-commons/nifi-security-utils/src/test/resources/openssl_bcrypt.rb b/nifi-commons/nifi-security-utils/src/test/resources/openssl_bcrypt.rb
index bcb62ce..a75bc6a 100755
--- a/nifi-commons/nifi-security-utils/src/test/resources/openssl_bcrypt.rb
+++ b/nifi-commons/nifi-security-utils/src/test/resources/openssl_bcrypt.rb
@@ -38,6 +38,7 @@ work_factor = 10
puts "Work factor: #{work_factor}"
key_len = cipher.key_len
digest = OpenSSL::Digest::SHA512.new
+puts "Digest: #{digest.to_s} #{digest.length}"
puts ""
@@ -46,10 +47,11 @@ puts "Hash: #{hash}"
full_salt = hash.salt
puts "Full Salt: #{full_salt} #{full_salt.length}"
-key = (digest.digest hash)[0..key_len - 1]
-salt = Base64.decode64(hash.salt[7..-1])
+key = (digest.digest hash[-31..-1])[0..key_len - 1]
+# salt = Base64.decode64(hash.salt[7..-1])
+b64Salt = hash.salt[7..-1]
-puts "Salt: #{bin_to_hex(salt)} #{salt.length}"
+puts "Salt: #{b64Salt} #{b64Salt.length * 3 / 4}"
puts " IV: #{bin_to_hex(iv)} #{iv.length}"
puts " Key: #{bin_to_hex(key)} #{key.length}"
cipher.key = key
diff --git a/nifi-commons/nifi-security-utils/src/test/resources/openssl_bcrypt_recovery.rb b/nifi-commons/nifi-security-utils/src/test/resources/openssl_bcrypt_recovery.rb
new file mode 100644
index 0000000..7d06f9b
--- /dev/null
+++ b/nifi-commons/nifi-security-utils/src/test/resources/openssl_bcrypt_recovery.rb
@@ -0,0 +1,94 @@
+#!/usr/bin/env ruby
+
+# 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.
+
+require 'openssl'
+require 'base64'
+
+# Run `$ gem install bcrypt` >= 2.1.4
+require 'bcrypt'
+
+def bin_to_hex(s)
+ s.each_byte.map { |b| b.to_s(16).rjust(2, '0') }.join
+end
+
+def hex_to_bin(s)
+ s.scan(/../).map { |x| x.hex.chr }.join
+end
+
+# Flowfile content from EncryptContent w/ Bcrypt (default cost params) + password: thisIsABadPassword
+ciphertext = hex_to_bin("24326124313224674449584f316a54774a774130386d714a76396545754e69466953414c54ef67ce4babc8016c055828a3065806034e69466949567231eac94e4c841508a82d2a5c98e3501b096774e98fc0039db89250f52fbb01")
+
+salt = ciphertext[0..28]
+salt_delimiter = ciphertext[29..36]
+
+cipher = OpenSSL::Cipher.new 'AES-128-CBC'
+cipher.decrypt
+iv = ciphertext[37..52]
+cipher.iv = iv
+iv_delimiter = ciphertext[53..59]
+
+cipher_bytes = ciphertext[59..-1]
+puts "Cipher bytes: #{bin_to_hex(cipher_bytes)} #{cipher_bytes.length}"
+
+password = 'thisIsABadPassword'
+puts "Password: #{password} #{password.length}"
+key_len = cipher.key_len
+digest = OpenSSL::Digest::SHA512.new
+
+puts ""
+
+hash = BCrypt::Engine.hash_secret(password, salt)
+puts "Hash: #{hash}"
+full_salt = hash[0..28]
+puts "Full Salt: #{full_salt} #{full_salt.length}"
+# hash_radix64 = hash[29..-1]
+hash_radix64 = hash
+puts "Hash R64: #{hash_radix64} #{hash_radix64.length}"
+
+# Convert from the Bcrypt Radix64 to binary
+# Use BcryptSecureHasherTest.groovy which exercises conversion methods in BcryptSecureHasher
+# hash_base64 = "K4sgDQwNApbod+8csAnQQo3yj9LYpXA"
+# Expected byte value in hex: 2b8b200d0c0d0296e877ef1cb009d0428df28fd2d8a570
+# hash_bytes = Base64.decode64 hash_base64
+# puts "Hash bytes: #{bin_to_hex(hash_bytes)}"
+
+# key = (digest.digest hash_bytes)[0..key_len - 1]
+
+raw_salt_radix64 = salt[7..-1]
+puts "Raw salt R64: #{raw_salt_radix64}"
+# Convert from the Bcrypt Radix64 to binary
+# Use BcryptSecureHasherTest.groovy which exercises conversion methods in BcryptSecureHasher
+# Expected byte value in hex: a28f4e90c971657684c255d69cc4cc93
+# Expected Base64 value: oo9OkMlxZXaEwlXWnMTMkw
+raw_salt_base64 = "oo9OkMlxZXaEwlXWnMTMkw"
+puts "Raw salt B64: #{raw_salt_base64}"
+raw_salt_bytes = Base64.decode64(raw_salt_base64)
+puts "Raw salt: #{bin_to_hex(raw_salt_bytes)}"
+
+# Only use the hash part of the full output as input to the key stretching digest
+key = (digest.digest hash_radix64[-31..-1])[0..key_len - 1]
+puts "Salt: #{bin_to_hex(raw_salt_bytes)} #{raw_salt_bytes.length}"
+puts " IV: #{bin_to_hex(iv)} #{iv.length}"
+puts " Key: #{bin_to_hex(key)} #{key.length}"
+cipher.key = key
+
+# Now decrypt the data:
+
+plaintext = cipher.update cipher_bytes
+plaintext << cipher.final
+puts "Plaintext length: #{plaintext.length}"
+puts "Plaintext: #{plaintext}"
\ No newline at end of file
diff --git a/nifi-commons/nifi-security-utils/src/test/resources/openssl_bcrypt.rb b/nifi-commons/nifi-security-utils/src/test/resources/openssl_pbkdf2_recovery.rb
old mode 100755
new mode 100644
similarity index 57%
copy from nifi-commons/nifi-security-utils/src/test/resources/openssl_bcrypt.rb
copy to nifi-commons/nifi-security-utils/src/test/resources/openssl_pbkdf2_recovery.rb
index bcb62ce..ee1f7eb
--- a/nifi-commons/nifi-security-utils/src/test/resources/openssl_bcrypt.rb
+++ b/nifi-commons/nifi-security-utils/src/test/resources/openssl_pbkdf2_recovery.rb
@@ -16,47 +16,47 @@
# limitations under the License.
require 'openssl'
-require 'base64'
-
-# Run `$ gem install bcrypt` >= 2.1.4
-require 'bcrypt'
def bin_to_hex(s)
s.each_byte.map { |b| b.to_s(16).rjust(2, '0') }.join
end
-plaintext = "This is a plaintext message."
-puts "Plaintext: #{plaintext}"
+def hex_to_bin(s)
+ s.scan(/../).map { |x| x.hex.chr }.join
+end
+
+# Flowfile content from EncryptContent w/ PBKDF2 (default cost params) + password: thisIsABadPassword
+ciphertext = hex_to_bin("A38A9085A702F5FA28C0485C52AF92E84E69466953414C549CB6114F13990FC197D53E05C73CAC9C4E694669495650F025A192EB62A324CA1CEBACF6D563657BE5DFF0BD601801DDA85FBBB350568D0BBA9A688ADE02A00F59C63527045E65D61D0B21C6451AF55B97C4420911C16511EFD59A714C1CABFCD80AF7FF81EA")
+
+salt = ciphertext[0..15]
+salt_delimiter = ciphertext[16..23]
cipher = OpenSSL::Cipher.new 'AES-128-CBC'
-cipher.encrypt
-iv = cipher.random_iv
+cipher.decrypt
+iv = ciphertext[24..39]
+cipher.iv = iv
+iv_delimiter = ciphertext[40..45]
+
+cipher_bytes = ciphertext[46..-1]
password = 'thisIsABadPassword'
puts "Password: #{password} #{password.length}"
-work_factor = 10
-puts "Work factor: #{work_factor}"
+iterations = 160_000
+puts "Iterations: #{iterations}"
key_len = cipher.key_len
digest = OpenSSL::Digest::SHA512.new
puts ""
-hash = BCrypt::Password.create(password, :cost => work_factor)
-puts "Hash: #{hash}"
-full_salt = hash.salt
-puts "Full Salt: #{full_salt} #{full_salt.length}"
-
-key = (digest.digest hash)[0..key_len - 1]
-salt = Base64.decode64(hash.salt[7..-1])
-
+key = OpenSSL::PKCS5.pbkdf2_hmac(password, salt, iterations, key_len, digest)
puts "Salt: #{bin_to_hex(salt)} #{salt.length}"
puts " IV: #{bin_to_hex(iv)} #{iv.length}"
puts " Key: #{bin_to_hex(key)} #{key.length}"
cipher.key = key
-# Now encrypt the data:
+# Now decrypt the data:
-encrypted = cipher.update plaintext
-encrypted << cipher.final
-puts "Cipher text length: #{encrypted.length}"
-puts "Cipher text: #{bin_to_hex(encrypted)}"
\ No newline at end of file
+plaintext = cipher.update cipher_bytes
+plaintext << cipher.final
+puts "Plaintext length: #{plaintext.length}"
+puts "Plaintext: #{plaintext}"
\ No newline at end of file
diff --git a/nifi-commons/nifi-security-utils/src/test/resources/openssl_scrypt_recovery.rb b/nifi-commons/nifi-security-utils/src/test/resources/openssl_scrypt_recovery.rb
new file mode 100644
index 0000000..6456521
--- /dev/null
+++ b/nifi-commons/nifi-security-utils/src/test/resources/openssl_scrypt_recovery.rb
@@ -0,0 +1,96 @@
+#!/usr/bin/env ruby
+
+# 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.
+
+require 'openssl'
+require 'base64'
+
+# Run `$ gem install scrypt`
+require 'scrypt'
+
+def bin_to_hex(s)
+ s.each_byte.map { |b| b.to_s(16).rjust(2, '0') }.join
+end
+
+def hex_to_bin(s)
+ s.scan(/../).map { |x| x.hex.chr }.join
+end
+
+# Extracts r and p directly, converts N from the hex encoding of the exponent to the hex representation of the calculated total iterations cost (e -> 14 -> 2^14 -> 16384 -> 0x4000)
+def format_ruby_cost(java_cost)
+ n_exp = java_cost[0].to_i(16)
+ r = java_cost[1..2].to_i
+ p = java_cost[3..-1].to_i
+
+ n_dec = 2 ** n_exp
+ [n_dec.to_s(16), r, p].join("$") + "$"
+end
+
+# Flowfile content from EncryptContent w/ Scrypt (default cost params) + password: thisIsABadPassword
+ciphertext = hex_to_bin("24733024 65303830 31247255 666B594B 784A356E 63557A62 744F7844 59524D51 4E694669 53414C54 E912E385 39EF934D 393CB0F8 B912F32E 4E694669 49562E35 748C7D5E 2762CE7C ACDC1B22 F2AFFCF0 1EB69F73 CC1ECFD9 04F034D7 8A91EFCB 08E23782 0B9F9301 C87B3ABB 65C86FD2 911045CE 7038D66A E0DE56BC 35F84937 D7D5131A 4FD01D0F 10944A43 F57B".gsub("\s", ""))
+
+salt = ciphertext[0..31]
+salt_delimiter = ciphertext[32..39]
+
+cipher = OpenSSL::Cipher.new 'AES-128-CBC'
+cipher.decrypt
+iv = ciphertext[40..55]
+cipher.iv = iv
+iv_delimiter = ciphertext[56..61]
+
+cipher_bytes = ciphertext[62..-1]
+puts "Cipher bytes: #{bin_to_hex(cipher_bytes)} #{cipher_bytes.length}"
+
+password = 'thisIsABadPassword'
+puts "Password: #{password} #{password.length}"
+key_len = cipher.key_len
+
+puts ""
+
+# 10$8$1 -> 0d16, 0d8, 0d1 -> 16 = 2^4
+# 4000$8$1 -> 0d16384, 0d8, 0d1 -> 16384 = 2^14
+#
+# Convert e0801 -> hex(2^0x0e)$8$1
+#
+# If N != hex_encoded(decimal power of 2), C code returns err -1
+#
+java_cost = salt[4..8]
+puts "Java cost: #{java_cost}"
+ruby_cost = format_ruby_cost(java_cost)
+puts "Ruby cost: #{ruby_cost}"
+
+# ruby_cost = "4000$8$1"
+raw_salt_bytes = Base64.decode64(salt[10..31])
+scrypt_ruby_salt = ruby_cost << bin_to_hex(raw_salt_bytes)
+puts "Ruby salt: #{scrypt_ruby_salt}"
+
+hash = SCrypt::Engine.hash_secret(password, scrypt_ruby_salt, key_len)
+puts "Hash: #{hash}"
+full_salt = hash[0..28]
+puts "Full Salt: #{full_salt} #{full_salt.length}"
+
+key = hex_to_bin(hash[-(key_len*2)..-1])
+puts "Salt: #{bin_to_hex(raw_salt_bytes)} #{raw_salt_bytes.length}"
+puts " IV: #{bin_to_hex(iv)} #{iv.length}"
+puts " Key: #{bin_to_hex(key)} #{key.length}"
+cipher.key = key
+
+# Now decrypt the data:
+
+plaintext = cipher.update cipher_bytes
+plaintext << cipher.final
+puts "Plaintext length: #{plaintext.length}"
+puts "Plaintext: #{plaintext}"
\ No newline at end of file
diff --git a/nifi-docs/src/main/asciidoc/administration-guide.adoc b/nifi-docs/src/main/asciidoc/administration-guide.adoc
index 26a1d2b..86777d2 100644
--- a/nifi-docs/src/main/asciidoc/administration-guide.adoc
+++ b/nifi-docs/src/main/asciidoc/administration-guide.adoc
@@ -1381,43 +1381,70 @@ The EncryptContent processor allows for the encryption and decryption of data, b
Key Derivation Functions (KDF) are mechanisms by which human-readable information, usually a password or other secret information, is translated into a cryptographic key suitable for data protection. For further information, read the link:https://en.wikipedia.org/wiki/Key_derivation_function[Wikipedia entry on Key Derivation Functions^].
Currently, KDFs are ingested by `CipherProvider` implementations and return a fully-initialized `Cipher` object to be used for encryption or decryption. Due to the use of a `CipherProviderFactory`, the KDFs are not customizable at this time. Future enhancements will include the ability to provide custom cost parameters to the KDF at initialization time. As a work-around, `CipherProvider` instances can be initialized with custom cost parameters in the constructor but this is not currently [...]
+If you do not have a need for a specific KDF, *Argon2 is recommended* as it is a robust, secure, performant, and user-friendly default and is widely supported on multiple platforms.
Here are the KDFs currently supported by NiFi (primarily in the `EncryptContent` processor for password-based encryption (PBE)) and relevant notes:
-* NiFi Legacy KDF
-** The original KDF used by NiFi for internal key derivation for PBE, this is 1000 iterations of the MD5 digest over the concatenation of the password and 8 or 16 bytes of random salt (the salt length depends on the selected cipher block size).
-** This KDF is *deprecated as of NiFi 0.5.0* and should only be used for backwards compatibility to decrypt data that was previously encrypted by a legacy version of NiFi.
-* OpenSSL PKCS#5 v1.5 EVP_BytesToKey
-** This KDF was added in v0.4.0.
-** This KDF is provided for compatibility with data encrypted using OpenSSL's default PBE, known as `EVP_BytesToKey`. This is a single iteration of MD5 over the concatenation of the password and 8 bytes of random ASCII salt. OpenSSL recommends using `PBKDF2` for key derivation but does not expose the library method necessary to the command-line tool, so this KDF is still the de facto default for command-line encryption.
-* Bcrypt
-** This KDF was added in v0.5.0.
-** link:https://en.wikipedia.org/wiki/Bcrypt[Bcrypt^] is an adaptive function based on the link:https://en.wikipedia.org/wiki/Blowfish_(cipher)[Blowfish^] cipher. This KDF is strongly recommended as it automatically incorporates a random 16 byte salt, configurable cost parameter (or "work factor"), and is hardened against brute-force attacks using link:https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units[GPGPU^] (which share memory between cores) by requir [...]
-** Because the length of a Bcrypt-derived key is always 184 bits, the complete output is then fed to a `SHA-512` digest and truncated to the desired key length. This provides the benefit of the avalanche effect on the formatted input.
-** The recommended minimum work factor is 12 (2^12^ key derivation rounds) (as of 2/1/2016 on commodity hardware) and should be increased to the threshold at which legitimate systems will encounter detrimental delays (see schedule below or use `BcryptCipherProviderGroovyTest#testDefaultConstructorShouldProvideStrongWorkFactor()` to calculate safe minimums).
-** The salt format is `$2a$10$ABCDEFGHIJKLMNOPQRSTUV`. The salt is delimited by `$` and the three sections are as follows:
-*** `2a` - the version of the format. An extensive explanation can be found link:http://blog.ircmaxell.com/2012/12/seven-ways-to-screw-up-bcrypt.html[here^]. NiFi currently uses `2a` for all salts generated internally.
-*** `10` - the work factor. This is actually the log~2~ value, so the total iteration count would be 2^10^ in this case.
-*** `ABCDEFGHIJKLMNOPQRSTUV` - the 22 character, Base64-encoded, unpadded, raw salt value. This decodes to a 16 byte salt used in the key derivation.
-* Scrypt
-** This KDF was added in v0.5.0.
-** link:https://en.wikipedia.org/wiki/Scrypt[Scrypt^] is an adaptive function designed in response to `bcrypt`. This KDF is recommended as it requires relatively large amounts of memory for each derivation, making it resistant to hardware brute-force attacks.
-** The recommended minimum cost is `N`=2^14^, `r`=8, `p`=1 (as of 2/1/2016 on commodity hardware). `p` must be a positive integer and less than `(2^32 − 1) * (Hlen/MFlen)` where `Hlen` is the length in octets of the digest function output (32 for SHA-256) and `MFlen` is the length in octets of the mixing function output, defined as `r * 128`. These parameters should be increased to the threshold at which legitimate systems will encounter detrimental delays (see schedule below or use `Scr [...]
-** The salt format is `$s0$e0101$ABCDEFGHIJKLMNOPQRSTUV`. The salt is delimited by `$` and the three sections are as follows:
-*** `s0` - the version of the format. NiFi currently uses `s0` for all salts generated internally.
-*** `e0101` - the cost parameters. This is actually a hexadecimal encoding of `N`, `r`, `p` using shifts. This can be formed/parsed using `Scrypt#encodeParams()` and `Scrypt#parseParameters()`.
-**** Some external libraries encode `N`, `r`, and `p` separately in the form `$400$1$1$`. A utility method is available at `ScryptCipherProvider#translateSalt()` which will convert the external form to the internal form.
-*** `ABCDEFGHIJKLMNOPQRSTUV` - the 12-44 character, Base64-encoded, unpadded, raw salt value. This decodes to a 8-32 byte salt used in the key derivation.
-* PBKDF2
-** This KDF was added in v0.5.0.
-** link:https://en.wikipedia.org/wiki/PBKDF2[Password-Based Key Derivation Function 2^] is an adaptive derivation function which uses an internal pseudorandom function (PRF) and iterates it many times over a password and salt (at least 16 bytes).
-** The PRF is recommended to be `HMAC/SHA-256` or `HMAC/SHA-512`. The use of an HMAC cryptographic hash function mitigates a length extension attack.
-** The recommended minimum number of iterations is 160,000 (as of 2/1/2016 on commodity hardware). This number should be doubled every two years (see schedule below or use `PBKDF2CipherProviderGroovyTest#testDefaultConstructorShouldProvideStrongIterationCount()` to calculate safe minimums).
-** This KDF is not memory-hard (can be parallelized massively with commodity hardware) but is still recommended as sufficient by http://csrc.nist.gov/publications/nistpubs/800-132/nist-sp800-132.pdf[NIST SP 800-132 (PDF)] and many cryptographers (when used with a proper iteration count and HMAC cryptographic hash function).
-* None
-** This KDF was added in v0.5.0.
-** This KDF performs no operation on the input and is a marker to indicate the raw key is provided to the cipher. The key must be provided in hexadecimal encoding and be of a valid length for the associated cipher/algorithm.
-
-==== Additional Resources
+[[nifi-legacy-kdf]]
+==== NiFi Legacy KDF
+* The original KDF used by NiFi for internal key derivation for PBE, this is 1000 iterations of the MD5 digest over the concatenation of the password and 8 or 16 bytes of random salt (the salt length depends on the selected cipher block size).
+* This KDF is *deprecated as of NiFi 0.5.0* and should only be used for backwards compatibility to decrypt data that was previously encrypted by a legacy version of NiFi.
+
+[[openssl-pkcs5-evp-bytestokey]]
+==== OpenSSL PKCS#5 v1.5 EVP_BytesToKey
+* This KDF was added in v0.4.0.
+* This KDF is provided for compatibility with data encrypted using OpenSSL's default PBE, known as `EVP_BytesToKey`. This is a single iteration of MD5 over the concatenation of the password and 8 bytes of random ASCII salt. OpenSSL recommends using `PBKDF2` for key derivation but does not expose the library method necessary to the command-line tool, so this KDF is still the de facto default for command-line encryption.
+
+[[bcrypt-kdf]]
+==== Bcrypt
+* This KDF was added in v0.5.0.
+* link:https://en.wikipedia.org/wiki/Bcrypt[Bcrypt^] is an adaptive function based on the link:https://en.wikipedia.org/wiki/Blowfish_(cipher)[Blowfish^] cipher. This KDF is recommended as it automatically incorporates a random 16 byte salt, configurable cost parameter (or "work factor"), and is hardened against brute-force attacks using link:https://en.wikipedia.org/wiki/General-purpose_computing_on_graphics_processing_units[GPGPU^] (which share memory between cores) by requiring access [...]
+* Because the length of a Bcrypt-derived hash is always 184 bits, the hash output (not including the algorithm, work factor, or salt) is then fed to a `SHA-512` digest and truncated to the desired key length. This provides the benefit of the avalanche effect over the input. This key stretching mechanism was introduced in Apache NiFi 1.12.0.
++
+NOTE: Prior to this, the _complete output_ (algorithm, work factor, salt, and hash output for a total of 480 bits) was provided to the SHA-512 digest function. NiFi can transparently handle decrypting data (under 10 MiB) encrypted using a key derived via this legacy process.
+* The recommended minimum work factor is 12 (2^12^ key derivation rounds) (as of 2/1/2016 on commodity hardware) and should be increased to the threshold at which legitimate systems will encounter detrimental delays (see schedule below or use `BcryptCipherProviderGroovyTest#testDefaultConstructorShouldProvideStrongWorkFactor()` to calculate safe minimums).
+* The salt format is `$2a$10$ABCDEFGHIJKLMNOPQRSTUV`. The salt is delimited by `$` and the three sections are as follows:
+** `2a` - the version of the format. An extensive explanation can be found link:http://blog.ircmaxell.com/2012/12/seven-ways-to-screw-up-bcrypt.html[here^]. NiFi currently uses `2a` for all salts generated internally.
+** `10` - the work factor. This is actually the log~2~ value, so the total iteration count would be 2^10^ (1024) in this case.
+** `ABCDEFGHIJKLMNOPQRSTUV` - the 22 character, Radix64-encoded, unpadded, raw salt value. This decodes to a 16 byte salt used in the key derivation.
++
+NOTE: The Bcrypt Radix64 encoding is *not* compatible with standard MIME Base64 encoding.
+
+[[scrypt-kdf]]
+==== Scrypt
+* This KDF was added in v0.5.0.
+* link:https://en.wikipedia.org/wiki/Scrypt[Scrypt^] is an adaptive function designed in response to `bcrypt`. This KDF is recommended as it requires relatively large amounts of memory for each derivation, making it resistant to hardware brute-force attacks.
+* The recommended minimum cost is `N`=2^14^ (16,384), `r`=8, `p`=1 (as of 2/1/2016 on commodity hardware). `p` must be a positive integer and less than `(2^32 − 1) * (Hlen/MFlen)` where `Hlen` is the length in octets of the digest function output (32 for SHA-256) and `MFlen` is the length in octets of the mixing function output, defined as `r * 128`. These parameters should be increased to the threshold at which legitimate systems will encounter detrimental delays (see schedule below or [...]
+* The salt format is `$s0$e0101$ABCDEFGHIJKLMNOPQRSTUV`. The salt is delimited by `$` and the three sections are as follows:
+** `s0` - the version of the format. NiFi currently uses `s0` for all salts generated internally.
+** `e0101` - the cost parameters. This is actually a hexadecimal encoding of `N`, `r`, `p` using shifts. This can be formed/parsed using `Scrypt#encodeParams()` and `Scrypt#parseParameters()`.
+*** Some external libraries encode `N`, `r`, and `p` separately in the form `$4000$1$1$` (`N` is stored in hex encoding as `0x4000`, which is `0d16384`, or 2^14^ as `0xe` = `0d14`). A utility method is available at `ScryptCipherProvider#translateSalt()` which will convert the external form to the internal form.
+** `ABCDEFGHIJKLMNOPQRSTUV` - the 12-44 character, Base64-encoded, unpadded, raw salt value. This decodes to a 8-32 byte salt used in the key derivation.
+
+[[pbkdf2-kdf]]
+==== PBKDF2
+* This KDF was added in v0.5.0.
+* link:https://en.wikipedia.org/wiki/PBKDF2[Password-Based Key Derivation Function 2^] is an adaptive derivation function which uses an internal pseudorandom function (PRF) and iterates it many times over a password and salt (at least 16 bytes).
+* The PRF is recommended to be `HMAC/SHA-256` or `HMAC/SHA-512`. The use of an HMAC cryptographic hash function mitigates a length extension attack.
+* The recommended minimum number of iterations is 160,000 (as of 2/1/2016 on commodity hardware). This number should be doubled every two years (see schedule below or use `PBKDF2CipherProviderGroovyTest#testDefaultConstructorShouldProvideStrongIterationCount()` to calculate safe minimums).
+* This KDF is not memory-hard (can be parallelized massively with commodity hardware) but is still recommended as sufficient by http://csrc.nist.gov/publications/nistpubs/800-132/nist-sp800-132.pdf[NIST SP 800-132 (PDF)^] and many cryptographers (when used with a proper iteration count and HMAC cryptographic hash function).
+
+[[none-kdf]]
+==== None
+* This KDF was added in v0.5.0.
+* This KDF performs no operation on the input and is a marker to indicate the raw key is provided to the cipher. The key must be provided in hexadecimal encoding and be of a valid length for the associated cipher/algorithm.
+
+[[argon2-kdf]]
+==== Argon2
+* This KDF was added in v1.12.0.
+* link:https://en.wikipedia.org/wiki/Argon2[Argon2^] is a key derivation function which won the Password Hashing Competition in 2015. This KDF is recommended as it offers a variety of modes which can be tailored to prevention of GPU attacks, prevention of side-channel attacks, or a combination of both. It allows for a variable output key length.
+* The recommended minimum cost is `memory`=2^16^ (65,536) KiB, `iterations`=5, `parallelism`=8 (as of 4/22/2020 on commodity hardware). The link:https://password-hashing.net/argon2-specs.pdf[Argon2 specification paper (PDF)^] Section 9 describes an algorithm used to determine recommended parameters. These parameters should be increased to the threshold at which legitimate systems will encounter detrimental delays (use `Argon2SecureHasherTest#testDefaultCostParamsShouldBeSufficient()` to [...]
+* The salt format is `$argon2id$v=19$m=65536,t=5,p=8$ABCDEFGHIJKLMNOPQRSTUV`. The salt is delimited by `$` and the four sections are as follows:
+** `argon2id` - the "type" of algorithm (`2i`, `2d`, `2id`). NiFi currently uses `argon2id` for all salts generated internally.
+** `v=19` - the version of the algorithm in decimal (`0d19` = `0x13`). NiFi currently uses `0d19` for all salts generated internally.
+** `m=65536,t=5,p=8` - the cost parameters. This contains the memory, iterations, and parallelism in order.
+** `ABCDEFGHIJKLMNOPQRSTUV` - the 12-44 character, Base64-encoded, unpadded, raw salt value. This decodes to a 8-32 byte salt used in the key derivation.
+
+===== Additional Resources
* link:http://stackoverflow.com/a/30308723/70465[Explanation of optimal scrypt cost parameters and relationships^]
* link:http://csrc.nist.gov/publications/nistpubs/800-132/nist-sp800-132.pdf[NIST Special Publication 800-132^]
@@ -1452,7 +1479,7 @@ image:openssl-salt.png["OpenSSL Salt Encoding"]
For new KDFs, each of which allow for non-deterministic IVs, the IV must be stored alongside the cipher text. This is not a vulnerability, as the IV is not required to be secret, but simply to be unique for messages encrypted using the same key to reduce the success of cryptographic attacks. For these KDFs, the output consists of the salt, followed by the salt delimiter, UTF-8 string "`NiFiSALT`" (`0x4E 69 46 69 53 41 4C 54`) and then the IV, followed by the IV delimiter, UTF-8 string "` [...]
-==== Bcrypt, Scrypt, PBKDF2
+==== Bcrypt, Scrypt, PBKDF2, Argon2
image:bcrypt-salt.png["Bcrypt Salt & IV Encoding"]
@@ -1460,6 +1487,8 @@ image:scrypt-salt.png["Scrypt Salt & IV Encoding"]
image:pbkdf2-salt.png["PBKDF2 Salt & IV Encoding"]
+image:argon2-salt.png["Argon2 Salt & IV Encoding"]
+
=== Java Cryptography Extension (JCE) Limited Strength Jurisdiction Policies
Because of US export regulations, default JVMs have link:http://docs.oracle.com/javase/7/docs/technotes/guides/security/SunProviders.html#importlimits[limits imposed on the strength of cryptographic operations^] available to them. For example, AES operations are limited to `128 bit keys` by default. While `AES-128` is cryptographically safe, this can have unintended consequences, specifically on Password-based Encryption (PBE).
diff --git a/nifi-docs/src/main/asciidoc/images/argon2-salt.png b/nifi-docs/src/main/asciidoc/images/argon2-salt.png
new file mode 100644
index 0000000..2437c6c
Binary files /dev/null and b/nifi-docs/src/main/asciidoc/images/argon2-salt.png differ
diff --git a/nifi-mock/src/main/java/org/apache/nifi/util/MockFlowFile.java b/nifi-mock/src/main/java/org/apache/nifi/util/MockFlowFile.java
index 3150dc2..375158c 100644
--- a/nifi-mock/src/main/java/org/apache/nifi/util/MockFlowFile.java
+++ b/nifi-mock/src/main/java/org/apache/nifi/util/MockFlowFile.java
@@ -22,6 +22,7 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
@@ -32,7 +33,6 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
-
import org.apache.commons.lang3.builder.HashCodeBuilder;
import org.apache.nifi.controller.repository.FlowFileRecord;
import org.apache.nifi.controller.repository.claim.ContentClaim;
@@ -59,7 +59,7 @@ public class MockFlowFile implements FlowFileRecord {
this.id = id;
entryDate = System.currentTimeMillis();
lastEnqueuedDate = entryDate;
- attributes.put(CoreAttributes.FILENAME.key(), String.valueOf(System.nanoTime()) + ".mockFlowFile");
+ attributes.put(CoreAttributes.FILENAME.key(), System.nanoTime() + ".mockFlowFile");
attributes.put(CoreAttributes.PATH.key(), "target");
final String uuid = UUID.randomUUID().toString();
@@ -74,7 +74,7 @@ public class MockFlowFile implements FlowFileRecord {
final Map<String, String> attributesToCopy = toCopy.getAttributes();
String filename = attributesToCopy.get(CoreAttributes.FILENAME.key());
if (filename == null) {
- filename = String.valueOf(System.nanoTime()) + ".mockFlowFile";
+ filename = System.nanoTime() + ".mockFlowFile";
}
attributes.put(CoreAttributes.FILENAME.key(), filename);
@@ -141,14 +141,42 @@ public class MockFlowFile implements FlowFileRecord {
return data.length;
}
- void setData(final byte[] data) {
+ /**
+ * Sets the value of the internal content for this mock flowfile. Would not exist in standard {@link FlowFile} implementations, but useful for complex test assertions.
+ *
+ * @param data the flowfile content as a byte[]
+ */
+ public void setData(final byte[] data) {
this.data = data;
}
- byte[] getData() {
+ /**
+ * Returns the value of the internal content for this mock flowfile. Would not exist in standard {@link FlowFile} implementations, but useful for complex test assertions.
+ *
+ * @return the internal flowfile content as a byte[]
+ */
+ public byte[] getData() {
return this.data;
}
+ /**
+ * Returns the value of the internal content for this mock flowfile as a UTF-8 encoded String. Would not exist in standard {@link FlowFile} implementations, but useful for complex test assertions.
+ *
+ * @return the internal flowfile content as a String
+ */
+ public String getContent() {
+ return new String(getData(), StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Returns the value of the internal content for this mock flowfile as an {@link InputStream}. Would not exist in standard {@link FlowFile} implementations, but useful for complex test assertions.
+ *
+ * @return the internal flowfile content as a stream
+ */
+ public InputStream getContentStream() {
+ return new ByteArrayInputStream(getData());
+ }
+
@Override
public boolean isPenalized() {
return penalized;
@@ -194,7 +222,7 @@ public class MockFlowFile implements FlowFileRecord {
public void assertAttributeNotExists(final String attributeName) {
Assert.assertFalse("Attribute " + attributeName + " should not exist on FlowFile, but exists with value "
- + attributes.get(attributeName), attributes.containsKey(attributeName));
+ + attributes.get(attributeName), attributes.containsKey(attributeName));
}
public void assertAttributeEquals(final String attributeName, final String expectedValue) {
@@ -274,7 +302,7 @@ public class MockFlowFile implements FlowFileRecord {
if ((fromStream & 0xFF) != (data[i] & 0xFF)) {
Assert.fail("FlowFile content differs from input at byte " + bytesRead + " with input having value "
- + (fromStream & 0xFF) + " and FlowFile having value " + (data[i] & 0xFF));
+ + (fromStream & 0xFF) + " and FlowFile having value " + (data[i] & 0xFF));
}
bytesRead++;
@@ -343,7 +371,7 @@ public class MockFlowFile implements FlowFileRecord {
}
public boolean isContentEqual(String expected) {
- return isContentEqual(expected, Charset.forName("UTF-8"));
+ return isContentEqual(expected, StandardCharsets.UTF_8);
}
public boolean isContentEqual(String expected, final Charset charset) {
diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/repository/crypto/EncryptedFileSystemRepository.java b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/repository/crypto/EncryptedFileSystemRepository.java
index a53df71..4b8adc4 100644
--- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/repository/crypto/EncryptedFileSystemRepository.java
+++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/repository/crypto/EncryptedFileSystemRepository.java
@@ -305,22 +305,16 @@ public class EncryptedFileSystemRepository extends FileSystemRepository {
ByteBuffer bb = ByteBuffer.allocate(4);
bb.putInt(b);
writeBytes(bb.array(), 0, 4);
-
- scc.setLength(bcos.getBytesWritten() - startingOffset);
}
@Override
public synchronized void write(final byte[] b) throws IOException {
writeBytes(b, 0, b.length);
-
- scc.setLength(bcos.getBytesWritten() - startingOffset);
}
@Override
public synchronized void write(final byte[] b, final int off, final int len) throws IOException {
writeBytes(b, off, len);
-
- scc.setLength(bcos.getBytesWritten() - startingOffset);
}
/**
@@ -338,6 +332,7 @@ public class EncryptedFileSystemRepository extends FileSystemRepository {
try {
cipherOutputStream.write(b, off, len);
+ scc.setLength(bcos.getBytesWritten() - startingOffset);
} catch (final IOException ioe) {
recycle = false;
throw new IOException("Failed to write to " + this, ioe);
diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/EncryptContent.java b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/EncryptContent.java
index b59b5fa..f083379 100644
--- a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/EncryptContent.java
+++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/EncryptContent.java
@@ -20,12 +20,16 @@ import java.nio.charset.StandardCharsets;
import java.security.Security;
import java.text.Normalizer;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.lang3.StringUtils;
@@ -36,6 +40,8 @@ import org.apache.nifi.annotation.behavior.SideEffectFree;
import org.apache.nifi.annotation.behavior.SupportsBatching;
import org.apache.nifi.annotation.behavior.SystemResource;
import org.apache.nifi.annotation.behavior.SystemResourceConsideration;
+import org.apache.nifi.annotation.behavior.WritesAttribute;
+import org.apache.nifi.annotation.behavior.WritesAttributes;
import org.apache.nifi.annotation.documentation.CapabilityDescription;
import org.apache.nifi.annotation.documentation.Tags;
import org.apache.nifi.components.AllowableValue;
@@ -69,9 +75,28 @@ import org.bouncycastle.openpgp.PGPEncryptedData;
@SideEffectFree
@SupportsBatching
@InputRequirement(Requirement.INPUT_REQUIRED)
-@Tags({"encryption", "decryption", "password", "JCE", "OpenPGP", "PGP", "GPG"})
-@CapabilityDescription("Encrypts or Decrypts a FlowFile using either symmetric encryption with a password and randomly generated salt, or asymmetric encryption using a public and secret key.")
+@Tags({"encryption", "decryption", "password", "JCE", "OpenPGP", "PGP", "GPG", "KDF", "Argon2", "Bcrypt", "Scrypt", "PBKDF2", "salt", "iv"})
+@CapabilityDescription("Encrypts or Decrypts a FlowFile using either symmetric encryption with a raw key or password " +
+ "and randomly generated salt, or asymmetric encryption using a public and secret key.")
@SystemResourceConsideration(resource = SystemResource.CPU)
+@WritesAttributes({
+ @WritesAttribute(attribute = "encryptcontent.action", description = "\"encrypted\" or \"decrypted\" depending on the processor action"),
+ @WritesAttribute(attribute = "encryptcontent.algorithm", description = "The algorithm used for the cryptographic operation"),
+ @WritesAttribute(attribute = "encryptcontent.cipher_text_length", description = "The cipher text length in bytes (including IV, salt, and delimiters if present). " +
+ "Determined from incoming content in decrypt mode; outgoing content in encrypt mode"),
+ @WritesAttribute(attribute = "encryptcontent.iv", description = "The Initialization Vector in hex encoding (if present)"),
+ @WritesAttribute(attribute = "encryptcontent.iv_length", description = "The IV length in bytes"),
+ @WritesAttribute(attribute = "encryptcontent.kdf", description = "The Key Derivation Function used if Password-Based Encryption was enabled. See Admin Guide - Key Derivation Functions"),
+ @WritesAttribute(attribute = "encryptcontent.kdf_salt", description = "The KDF-specific salt including algorithm and cost parameters (if present). See Admin Guide - Key Derivation Functions"),
+ @WritesAttribute(attribute = "encryptcontent.kdf_salt_length", description = "The KDF salt length in bytes"),
+ @WritesAttribute(attribute = "encryptcontent.pbkdf2_iterations", description = "The number of iterations used in " +
+ "PBKDF2 KDF (if present). PBKDF2 does not encode the cost parameter in a custom salt"),
+ @WritesAttribute(attribute = "encryptcontent.plaintext_length", description = "The plaintext length in bytes. " +
+ "Determined from incoming content in encrypt mode; outgoing content in decrypt mode"),
+ @WritesAttribute(attribute = "encryptcontent.salt", description = "The raw salt in hex encoding (if present)"),
+ @WritesAttribute(attribute = "encryptcontent.salt_length", description = "The raw salt length in bytes"),
+ @WritesAttribute(attribute = "encryptcontent.timestamp", description = "The timestamp at which the cryptographic operation occurred in 'yyyy-MM-dd HH:mm:ss.SSS Z' format"),
+ })
public class EncryptContent extends AbstractProcessor {
public static final String ENCRYPT_MODE = "Encrypt";
@@ -80,8 +105,22 @@ public class EncryptContent extends AbstractProcessor {
private static final String WEAK_CRYPTO_ALLOWED_NAME = "allowed";
private static final String WEAK_CRYPTO_NOT_ALLOWED_NAME = "not-allowed";
+ public static final String IV_ATTR = "encryptcontent.iv";
+ public static final String IV_LEN_ATTR = "encryptcontent.iv_length";
+ public static final String SALT_ATTR = "encryptcontent.salt";
+ public static final String SALT_LEN_ATTR = "encryptcontent.salt_length";
+ public static final String KDF_SALT_ATTR = "encryptcontent.kdf_salt";
+ public static final String KDF_SALT_LEN_ATTR = "encryptcontent.kdf_salt_length";
+ public static final String PT_LEN_ATTR = "encryptcontent.plaintext_length";
+ public static final String CT_LEN_ATTR = "encryptcontent.cipher_text_length";
+ public static final String TS_ATTR = "encryptcontent.timestamp";
+ public static final String ACTION_ATTR = "encryptcontent.action";
+ public static final String ALGORITHM_ATTR = "encryptcontent.algorithm";
+ public static final String KDF_ATTR = "encryptcontent.kdf";
+
public static final PropertyDescriptor MODE = new PropertyDescriptor.Builder()
.name("Mode")
+ .displayName("Mode")
.description("Specifies whether the content should be encrypted or decrypted")
.required(true)
.allowableValues(ENCRYPT_MODE, DECRYPT_MODE)
@@ -93,17 +132,19 @@ public class EncryptContent extends AbstractProcessor {
.description("Specifies the key derivation function to generate the key from the password (and salt)")
.required(true)
.allowableValues(buildKeyDerivationFunctionAllowableValues())
- .defaultValue(KeyDerivationFunction.BCRYPT.name())
+ .defaultValue(KeyDerivationFunction.NONE.name())
.build();
public static final PropertyDescriptor ENCRYPTION_ALGORITHM = new PropertyDescriptor.Builder()
.name("Encryption Algorithm")
+ .displayName("Encryption Algorithm")
.description("The Encryption Algorithm to use")
.required(true)
.allowableValues(buildEncryptionMethodAllowableValues())
- .defaultValue(EncryptionMethod.MD5_128AES.name())
+ .defaultValue(EncryptionMethod.AES_GCM.name())
.build();
public static final PropertyDescriptor PASSWORD = new PropertyDescriptor.Builder()
.name("Password")
+ .displayName("Password")
.description("The Password to use for encrypting or decrypting the data")
.required(false)
.addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
@@ -186,7 +227,7 @@ public class EncryptContent extends AbstractProcessor {
final KeyDerivationFunction[] keyDerivationFunctions = KeyDerivationFunction.values();
List<AllowableValue> allowableValues = new ArrayList<>(keyDerivationFunctions.length);
for (KeyDerivationFunction kdf : keyDerivationFunctions) {
- allowableValues.add(new AllowableValue(kdf.name(), kdf.getName(), kdf.getDescription()));
+ allowableValues.add(new AllowableValue(kdf.name(), kdf.getKdfName(), kdf.getDescription()));
}
return allowableValues.toArray(new AllowableValue[0]);
@@ -217,7 +258,7 @@ public class EncryptContent extends AbstractProcessor {
private static AllowableValue[] buildPGPSymmetricCipherAllowableValues() {
// Allowed values are inferred from SymmetricKeyAlgorithmTags. Note that NULL and SAFER cipher are not supported and therefore not listed
- return new AllowableValue[] {
+ return new AllowableValue[]{
new AllowableValue("1", "IDEA"),
new AllowableValue("2", "TRIPLE_DES"),
new AllowableValue("3", "CAST5"),
@@ -229,7 +270,7 @@ public class EncryptContent extends AbstractProcessor {
new AllowableValue("10", "TWOFISH"),
new AllowableValue("11", "CAMELLIA_128"),
new AllowableValue("12", "CAMELLIA_192"),
- new AllowableValue("13", "CAMELLIA_256") };
+ new AllowableValue("13", "CAMELLIA_256")};
}
@Override
@@ -291,10 +332,10 @@ public class EncryptContent extends AbstractProcessor {
validationResults.addAll(validatePGP(encryptionMethod, password, encrypt, publicKeyring, publicUserId,
privateKeyring, privateKeyringPassphrase, cipher));
} else { // Not PGP
- if (encryptionMethod.isKeyedCipher()) { // Raw key
- validationResults.addAll(validateKeyed(encryptionMethod, kdf, keyHex));
+ boolean allowWeakCrypto = context.getProperty(ALLOW_WEAK_CRYPTO).getValue().equalsIgnoreCase(WEAK_CRYPTO_ALLOWED_NAME);
+ if (encryptionMethod.isKeyedCipher()) { // Raw key or derived key from password
+ validationResults.addAll(validateKeyed(encryptionMethod, kdf, keyHex, password, allowWeakCrypto));
} else { // PBE
- boolean allowWeakCrypto = context.getProperty(ALLOW_WEAK_CRYPTO).getValue().equalsIgnoreCase(WEAK_CRYPTO_ALLOWED_NAME);
validationResults.addAll(validatePBE(encryptionMethod, kdf, password, allowWeakCrypto));
}
}
@@ -316,7 +357,7 @@ public class EncryptContent extends AbstractProcessor {
String privateKeyringPassphrase, int cipher) {
List<ValidationResult> validationResults = new ArrayList<>();
- if(encrypt && password != null && !isValidCipher(cipher)) {
+ if (encrypt && password != null && !isValidCipher(cipher)) {
validationResults.add(new ValidationResult.Builder().subject(PGP_SYMMETRIC_ENCRYPTION_CIPHER.getDisplayName())
.explanation("When performing an encryption with " + encryptionMethod.getAlgorithm() + " and a symmetric " +
PASSWORD.getDisplayName() + ", a" + PGP_SYMMETRIC_ENCRYPTION_CIPHER.getDisplayName() + " is required")
@@ -378,13 +419,30 @@ public class EncryptContent extends AbstractProcessor {
}
private List<ValidationResult> validatePBE(EncryptionMethod encryptionMethod, KeyDerivationFunction kdf, String password, boolean allowWeakCrypto) {
+ // Start by validating the password specifically
+ List<ValidationResult> validationResults = validatePassword(encryptionMethod, kdf, password, allowWeakCrypto);
+
+ // Check the KDF for compatibility with this algorithm
+ List<String> kdfsForPBECipher = getKDFsForPBECipher(encryptionMethod);
+ if (kdf == null || !kdfsForPBECipher.contains(kdf.name())) {
+ final String displayName = KEY_DERIVATION_FUNCTION.getDisplayName();
+ validationResults.add(new ValidationResult.Builder().subject(displayName)
+ .explanation(displayName + " is required to be " + StringUtils.join(kdfsForPBECipher,
+ ", ") + " when using algorithm " + encryptionMethod.getAlgorithm() + ". See Admin Guide.").build());
+ }
+
+ return validationResults;
+ }
+
+ private List<ValidationResult> validatePassword(EncryptionMethod encryptionMethod, KeyDerivationFunction kdf, String password, boolean allowWeakCrypto) {
List<ValidationResult> validationResults = new ArrayList<>();
- boolean limitedStrengthCrypto = !PasswordBasedEncryptor.supportsUnlimitedStrength();
+
+ boolean limitedStrengthCrypto = !CipherUtility.isUnlimitedStrengthCryptoSupported();
// Password required (short circuits validation because other conditions depend on password presence)
if (StringUtils.isEmpty(password)) {
validationResults.add(new ValidationResult.Builder().subject(PASSWORD.getName())
- .explanation(PASSWORD.getDisplayName() + " is required when using algorithm " + encryptionMethod.getAlgorithm()).build());
+ .explanation(PASSWORD.getDisplayName() + " is required when using algorithm " + encryptionMethod.getAlgorithm() + " and KDF " + kdf.getKdfName()).build());
return validationResults;
}
@@ -417,21 +475,13 @@ public class EncryptContent extends AbstractProcessor {
}
}
- // Check the KDF for compatibility with this algorithm
- List<String> kdfsForPBECipher = getKDFsForPBECipher(encryptionMethod);
- if (kdf == null || !kdfsForPBECipher.contains(kdf.name())) {
- final String displayName = KEY_DERIVATION_FUNCTION.getDisplayName();
- validationResults.add(new ValidationResult.Builder().subject(displayName)
- .explanation(displayName + " is required to be " + StringUtils.join(kdfsForPBECipher,
- ", ") + " when using algorithm " + encryptionMethod.getAlgorithm() + ". See Admin Guide.").build());
- }
-
return validationResults;
}
- private List<ValidationResult> validateKeyed(EncryptionMethod encryptionMethod, KeyDerivationFunction kdf, String keyHex) {
+
+ private List<ValidationResult> validateKeyed(EncryptionMethod encryptionMethod, KeyDerivationFunction kdf, String keyHex, String password, boolean allowWeakCrypto) {
List<ValidationResult> validationResults = new ArrayList<>();
- boolean limitedStrengthCrypto = !PasswordBasedEncryptor.supportsUnlimitedStrength();
+ boolean limitedStrengthCrypto = !CipherUtility.isUnlimitedStrengthCryptoSupported();
if (limitedStrengthCrypto) {
if (encryptionMethod.isUnlimitedStrength()) {
@@ -442,63 +492,80 @@ public class EncryptContent extends AbstractProcessor {
}
int allowedKeyLength = PasswordBasedEncryptor.getMaxAllowedKeyLength(ENCRYPTION_ALGORITHM.getName());
- if (StringUtils.isEmpty(keyHex)) {
- validationResults.add(new ValidationResult.Builder().subject(RAW_KEY_HEX.getName())
- .explanation(RAW_KEY_HEX.getDisplayName() + " is required when using algorithm " + encryptionMethod.getAlgorithm() + ". See Admin Guide.").build());
- } else {
- byte[] keyBytes = new byte[0];
- try {
- keyBytes = Hex.decodeHex(keyHex.toCharArray());
- } catch (DecoderException e) {
+ // Scenario 1: RKH is present & KDF == NONE
+ if (kdf == KeyDerivationFunction.NONE) {
+ if (StringUtils.isEmpty(keyHex)) {
validationResults.add(new ValidationResult.Builder().subject(RAW_KEY_HEX.getName())
- .explanation("Key must be valid hexadecimal string. See Admin Guide.").build());
- }
- if (keyBytes.length * 8 > allowedKeyLength) {
- validationResults.add(new ValidationResult.Builder().subject(RAW_KEY_HEX.getName())
- .explanation("Key length greater than " + allowedKeyLength + " bits is not supported by this JVM" +
- " due to lacking JCE Unlimited Strength Jurisdiction Policy files. See Admin Guide.").build());
+ .explanation(RAW_KEY_HEX.getDisplayName() +
+ " is required when using algorithm " +
+ encryptionMethod.getAlgorithm() +
+ " and KDF " +
+ KeyDerivationFunction.NONE +
+ ". See Admin Guide.").build());
+ } else {
+ validateKeyHex(encryptionMethod, keyHex, validationResults, allowedKeyLength);
}
- if (!CipherUtility.isValidKeyLengthForAlgorithm(keyBytes.length * 8, encryptionMethod.getAlgorithm())) {
- List<Integer> validKeyLengths = CipherUtility.getValidKeyLengthsForAlgorithm(encryptionMethod.getAlgorithm());
- validationResults.add(new ValidationResult.Builder().subject(RAW_KEY_HEX.getName())
- .explanation("Key must be valid length [" + StringUtils.join(validKeyLengths, ", ") + "]. See Admin Guide.").build());
+ } else if (kdf.isStrongKDF()) {
+ // Scenario 2: PW is present & KDF is strong
+ if (StringUtils.isEmpty(password)) {
+ validationResults.add(new ValidationResult.Builder().subject(PASSWORD.getName())
+ .explanation(PASSWORD.getDisplayName() +
+ " is required when using algorithm " +
+ encryptionMethod.getAlgorithm() +
+ " and KDF " +
+ kdf.getKdfName() +
+ ". See Admin Guide.").build());
+ } else {
+ // Password must still be validated
+ validationResults.addAll(validatePassword(encryptionMethod, kdf, password, allowWeakCrypto));
}
- }
-
- // Perform some analysis on the selected encryption algorithm to ensure the JVM can support it and the associated key
-
- List<String> kdfsForKeyedCipher = getKDFsForKeyedCipher();
- if (kdf == null || !kdfsForKeyedCipher.contains(kdf.name())) {
+ } else {
+ // KDF is legacy; not eligible for keyed cipher algorithm
+ List<String> kdfsForKeyedCipher = getKDFsForKeyedCipher();
validationResults.add(new ValidationResult.Builder().subject(KEY_DERIVATION_FUNCTION.getName())
.explanation(KEY_DERIVATION_FUNCTION.getDisplayName() + " is required to be " + StringUtils.join(kdfsForKeyedCipher, ", ") + " when using algorithm " +
encryptionMethod.getAlgorithm()).build());
}
-
return validationResults;
}
- private List<String> getKDFsForKeyedCipher() {
- List<String> kdfsForKeyedCipher = new ArrayList<>();
- kdfsForKeyedCipher.add(KeyDerivationFunction.NONE.name());
- for (KeyDerivationFunction k : KeyDerivationFunction.values()) {
- if (k.isStrongKDF()) {
- kdfsForKeyedCipher.add(k.name());
- }
+ private void validateKeyHex(EncryptionMethod encryptionMethod, String keyHex, List<ValidationResult> validationResults, int allowedKeyLength) {
+ byte[] keyBytes = new byte[0];
+ try {
+ keyBytes = Hex.decodeHex(keyHex.toCharArray());
+ } catch (DecoderException e) {
+ validationResults.add(new ValidationResult.Builder().subject(RAW_KEY_HEX.getName())
+ .explanation("Key must be valid hexadecimal string. See Admin Guide.").build());
+ }
+ if (keyBytes.length * 8 > allowedKeyLength) {
+ validationResults.add(new ValidationResult.Builder().subject(RAW_KEY_HEX.getName())
+ .explanation("Key length greater than " + allowedKeyLength + " bits is not supported by this JVM" +
+ " due to lacking JCE Unlimited Strength Jurisdiction Policy files. See Admin Guide.").build());
}
- return kdfsForKeyedCipher;
+ if (!CipherUtility.isValidKeyLengthForAlgorithm(keyBytes.length * 8, encryptionMethod.getAlgorithm())) {
+ List<Integer> validKeyLengths = CipherUtility.getValidKeyLengthsForAlgorithm(encryptionMethod.getAlgorithm());
+ validationResults.add(new ValidationResult.Builder().subject(RAW_KEY_HEX.getName())
+ .explanation("Key must be valid length [" + StringUtils.join(validKeyLengths, ", ") + "]. See Admin Guide.").build());
+ }
+ }
+
+ private List<String> getKDFsForKeyedCipher() {
+ List<String> kdfs = Arrays.stream(KeyDerivationFunction.values()).filter(KeyDerivationFunction::isStrongKDF).map(Enum::name).collect(Collectors.toList());
+ kdfs.add(KeyDerivationFunction.NONE.name());
+ return kdfs;
}
private List<String> getKDFsForPBECipher(EncryptionMethod encryptionMethod) {
- List<String> kdfsForPBECipher = new ArrayList<>();
- for (KeyDerivationFunction k : KeyDerivationFunction.values()) {
- // Add all weak (legacy) KDFs except NONE
- if (!k.isStrongKDF() && !k.equals(KeyDerivationFunction.NONE)) {
- kdfsForPBECipher.add(k.name());
- // If this algorithm supports strong KDFs, add them as well
- } else if ((encryptionMethod.isCompatibleWithStrongKDFs() && k.isStrongKDF())) {
- kdfsForPBECipher.add(k.name());
- }
+ List<String> kdfsForPBECipher;
+ if (encryptionMethod.isCompatibleWithStrongKDFs()) {
+ // Collect all
+ kdfsForPBECipher = Arrays.stream(KeyDerivationFunction.values()).map(Enum::name).collect(Collectors.toList());
+ } else {
+ // Collect all weak KDFS
+ kdfsForPBECipher = Arrays.stream(KeyDerivationFunction.values()).filter(kdf -> !kdf.isStrongKDF()).map(Enum::name).collect(Collectors.toList());
}
+
+ kdfsForPBECipher.remove(KeyDerivationFunction.NONE.name());
return kdfsForPBECipher;
}
@@ -523,25 +590,11 @@ public class EncryptContent extends AbstractProcessor {
StreamCallback callback;
try {
if (isPGPAlgorithm(algorithm)) {
- final String filename = flowFile.getAttribute(CoreAttributes.FILENAME.key());
- final String publicKeyring = context.getProperty(PUBLIC_KEYRING).getValue();
- final String privateKeyring = context.getProperty(PRIVATE_KEYRING).getValue();
- if (encrypt && publicKeyring != null) {
- final String publicUserId = context.getProperty(PUBLIC_KEY_USERID).getValue();
- encryptor = new OpenPGPKeyBasedEncryptor(algorithm, pgpCipher, providerName, publicKeyring, publicUserId, null, filename);
- } else if (!encrypt && privateKeyring != null) {
- final char[] keyringPassphrase = context.getProperty(PRIVATE_KEYRING_PASSPHRASE).evaluateAttributeExpressions().getValue().toCharArray();
- encryptor = new OpenPGPKeyBasedEncryptor(algorithm, pgpCipher, providerName, privateKeyring, null, keyringPassphrase, filename);
- } else {
- final char[] passphrase = Normalizer.normalize(password, Normalizer.Form.NFC).toCharArray();
- encryptor = new OpenPGPPasswordBasedEncryptor(algorithm, pgpCipher, providerName, passphrase, filename);
- }
+ encryptor = createPGPEncryptor(context, flowFile, providerName, algorithm, pgpCipher, password, encrypt);
} else if (kdf.equals(KeyDerivationFunction.NONE)) { // Raw key
- final String keyHex = context.getProperty(RAW_KEY_HEX).getValue();
- encryptor = new KeyedEncryptor(encryptionMethod, Hex.decodeHex(keyHex.toCharArray()));
+ encryptor = createKeyedEncryptor(context, encryptionMethod);
} else { // PBE
- final char[] passphrase = Normalizer.normalize(password, Normalizer.Form.NFC).toCharArray();
- encryptor = new PasswordBasedEncryptor(encryptionMethod, passphrase, kdf);
+ encryptor = createPBEEncryptor(encryptionMethod, password, kdf);
}
if (encrypt) {
@@ -560,6 +613,12 @@ public class EncryptContent extends AbstractProcessor {
try {
final StopWatch stopWatch = new StopWatch(true);
flowFile = session.write(flowFile, callback);
+
+ // Update the flowfile attributes
+ Map<String, String> clonedAttributes = new HashMap<>(flowFile.getAttributes());
+ encryptor.updateAttributes(clonedAttributes);
+ flowFile = session.putAllAttributes(flowFile, clonedAttributes);
+
logger.info("successfully {}crypted {}", new Object[]{encrypt ? "en" : "de", flowFile});
session.getProvenanceReporter().modifyContent(flowFile, stopWatch.getElapsed(TimeUnit.MILLISECONDS));
session.transfer(flowFile, REL_SUCCESS);
@@ -569,10 +628,44 @@ public class EncryptContent extends AbstractProcessor {
}
}
+ private Encryptor createPGPEncryptor(ProcessContext context, FlowFile flowFile, String providerName, String algorithm, Integer pgpCipher, String password, boolean encrypt) {
+ Encryptor encryptor;
+ final String filename = flowFile.getAttribute(CoreAttributes.FILENAME.key());
+ final String publicKeyring = context.getProperty(PUBLIC_KEYRING).getValue();
+ final String privateKeyring = context.getProperty(PRIVATE_KEYRING).getValue();
+ if (encrypt && publicKeyring != null) {
+ final String publicUserId = context.getProperty(PUBLIC_KEY_USERID).getValue();
+ encryptor = new OpenPGPKeyBasedEncryptor(algorithm, pgpCipher, providerName, publicKeyring, publicUserId, null, filename);
+ } else if (!encrypt && privateKeyring != null) {
+ final char[] keyringPassphrase = context.getProperty(PRIVATE_KEYRING_PASSPHRASE).evaluateAttributeExpressions().getValue().toCharArray();
+ encryptor = new OpenPGPKeyBasedEncryptor(algorithm, pgpCipher, providerName, privateKeyring, null, keyringPassphrase, filename);
+ } else {
+ final char[] passphrase = Normalizer.normalize(password, Normalizer.Form.NFC).toCharArray();
+ encryptor = new OpenPGPPasswordBasedEncryptor(algorithm, pgpCipher, providerName, passphrase, filename);
+ }
+ return encryptor;
+ }
+
+ private Encryptor createKeyedEncryptor(ProcessContext context, EncryptionMethod encryptionMethod) throws DecoderException {
+ Encryptor encryptor;
+ final String keyHex = context.getProperty(RAW_KEY_HEX).getValue();
+ encryptor = new KeyedEncryptor(encryptionMethod, Hex.decodeHex(keyHex.toCharArray()));
+ return encryptor;
+ }
+
+ private Encryptor createPBEEncryptor(EncryptionMethod encryptionMethod, String password, KeyDerivationFunction kdf) {
+ Encryptor encryptor;
+ final char[] passphrase = Normalizer.normalize(password, Normalizer.Form.NFC).toCharArray();
+ encryptor = new PasswordBasedEncryptor(encryptionMethod, passphrase, kdf);
+ return encryptor;
+ }
+
public interface Encryptor {
StreamCallback getEncryptionCallback() throws Exception;
StreamCallback getDecryptionCallback() throws Exception;
+
+ void updateAttributes(Map<String, String> attributes);
}
}
diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/AbstractEncryptor.java b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/AbstractEncryptor.java
new file mode 100644
index 0000000..5360c11
--- /dev/null
+++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/AbstractEncryptor.java
@@ -0,0 +1,103 @@
+/*
+ * 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 java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.commons.codec.binary.Hex;
+import org.apache.nifi.processors.standard.EncryptContent;
+import org.apache.nifi.security.util.EncryptionMethod;
+import org.apache.nifi.security.util.KeyDerivationFunction;
+import org.apache.nifi.stream.io.ByteCountingInputStream;
+import org.apache.nifi.stream.io.ByteCountingOutputStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public abstract class AbstractEncryptor implements EncryptContent.Encryptor {
+ private static final Logger logger = LoggerFactory.getLogger(AbstractEncryptor.class);
+
+ private static final String MODIFIABLE_CHECK_KEY = "__NiFi_EncryptContent_Check__";
+ protected transient Map<String, String> flowfileAttributes = new HashMap<>();
+
+ /**
+ * Updates the provided flowfile attribute map with the new attributes from this encryption/decryption operation.
+ *
+ * @param attributes a modifiable map of attributes
+ */
+ @Override
+ public void updateAttributes(Map<String, String> attributes) {
+ if (attributes == null) {
+ throw new IllegalArgumentException("Cannot update null flowfile attributes");
+ }
+
+ try {
+ attributes.put(MODIFIABLE_CHECK_KEY, "");
+ attributes.remove(MODIFIABLE_CHECK_KEY);
+ } catch (UnsupportedOperationException e) {
+ throw new IllegalArgumentException("The provided attributes map must be modifiable");
+ }
+
+ attributes.putAll(flowfileAttributes);
+ if (logger.isDebugEnabled()) {
+ logger.debug("Added {} encryption metadata attributes to flowfile", flowfileAttributes.size());
+ }
+ }
+
+ /**
+ * Returns the map of "standard" {@code EncryptContent} attributes set for the provided operation.
+ *
+ * @param encryptionMethod the algorithm used
+ * @param kdf the {@link KeyDerivationFunction} used
+ * @param iv the Initialization Vector in byte[] form
+ * @param bcis the {@link ByteCountingInputStream} (plaintext for {@code encrypt}; cipher text for {@code decrypt})
+ * @param bcos the {@link ByteCountingOutputStream} (reverse of input stream)
+ * @param encryptMode {@code true} for {@code encrypt}; {@code false} for {@code decrypt}
+ * @return the map of attributes to be added to the flowfile
+ */
+ static Map<String, String> writeAttributes(EncryptionMethod encryptionMethod,
+ KeyDerivationFunction kdf, byte[] iv, ByteCountingInputStream bcis, ByteCountingOutputStream bcos, boolean encryptMode) {
+ Map<String, String> attributes = new HashMap<>();
+ attributes.put(EncryptContent.ACTION_ATTR, encryptMode ? "encrypted" : "decrypted");
+ attributes.put(EncryptContent.ALGORITHM_ATTR, encryptionMethod.name());
+ attributes.put(EncryptContent.KDF_ATTR, kdf.name());
+ attributes.put(EncryptContent.TS_ATTR, CipherUtility.getTimestampString());
+ attributes.put(EncryptContent.IV_ATTR, iv != null ? Hex.encodeHexString(iv) : "N/A");
+ attributes.put(EncryptContent.IV_LEN_ATTR, iv != null ? String.valueOf(iv.length) : "0");
+
+ // If encrypting, the plaintext is the input and the cipher text is the output
+ if (encryptMode) {
+ attributes.put(EncryptContent.PT_LEN_ATTR, String.valueOf(bcis.getBytesRead()));
+ attributes.put(EncryptContent.CT_LEN_ATTR, String.valueOf(bcos.getBytesWritten()));
+ } else {
+ // If decrypting, switch the streams
+ attributes.put(EncryptContent.PT_LEN_ATTR, String.valueOf(bcos.getBytesWritten()));
+ attributes.put(EncryptContent.CT_LEN_ATTR, String.valueOf(bcis.getBytesRead()));
+ }
+
+ if (logger.isDebugEnabled()) {
+ final ArrayList<String> sortedKeys = new ArrayList<>(attributes.keySet());
+ Collections.sort(sortedKeys);
+ for (String k : sortedKeys) {
+ logger.debug("Added encryption metadata attribute: {} -> {}", k, attributes.get(k));
+ }
+ }
+
+ return attributes;
+ }
+}
diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/KeyedEncryptor.java b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/KeyedEncryptor.java
index 011eb1f..b5cb709 100644
--- a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/KeyedEncryptor.java
+++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/KeyedEncryptor.java
@@ -23,14 +23,19 @@ import java.security.NoSuchAlgorithmException;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
+import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.processor.exception.ProcessException;
import org.apache.nifi.processor.io.StreamCallback;
-import org.apache.nifi.processors.standard.EncryptContent.Encryptor;
import org.apache.nifi.security.util.EncryptionMethod;
import org.apache.nifi.security.util.KeyDerivationFunction;
+import org.apache.nifi.stream.io.ByteCountingInputStream;
+import org.apache.nifi.stream.io.ByteCountingOutputStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
-public class KeyedEncryptor implements Encryptor {
+public class KeyedEncryptor extends AbstractEncryptor {
+ private static final Logger logger = LoggerFactory.getLogger(KeyedEncryptor.class);
private EncryptionMethod encryptionMethod;
private SecretKey key;
@@ -124,18 +129,43 @@ public class KeyedEncryptor implements Encryptor {
// Initialize cipher provider
KeyedCipherProvider cipherProvider = (KeyedCipherProvider) CipherProviderFactory.getCipherProvider(KeyDerivationFunction.NONE);
+ // Wrap the streams for byte counting if necessary
+ ByteCountingInputStream bcis = CipherUtility.wrapStreamForCounting(in);
+ ByteCountingOutputStream bcos = CipherUtility.wrapStreamForCounting(out);
+
// Generate cipher
+ Cipher cipher;
try {
- Cipher cipher;
+ if (!bcis.markSupported()) {
+ logger.warn("The incoming cipher text stream does not support #mark(); unable to scan for possible salt");
+ } else {
+ // Skip salt if present (could have been used during encrypt in combination with KDF)
+ bcis.mark(100);
+ byte[] first80Bytes = new byte[80];
+ IOUtils.read(bcis, first80Bytes, 0, first80Bytes.length);
+ final int saltDelimiterStart = CipherUtility.findSequence(first80Bytes, RandomIVPBECipherProvider.SALT_DELIMITER);
+
+ // Reset whether salt is detected or not (to skip salt or read IV)
+ bcis.reset();
+ if (saltDelimiterStart != -1) {
+ byte[] saltBytes = new byte[saltDelimiterStart + RandomIVPBECipherProvider.SALT_DELIMITER.length];
+ IOUtils.readFully(bcis, saltBytes);
+ logger.info("Detected salt in incoming cipher text; skipped {} bytes", saltBytes.length);
+ }
+ }
+
// The IV could have been set by the constructor, but if not, read from the cipher stream
if (iv.length == 0) {
- iv = cipherProvider.readIV(in);
+ iv = cipherProvider.readIV(bcis);
}
cipher = cipherProvider.getCipher(encryptionMethod, key, iv, false);
- CipherUtility.processStreams(cipher, in, out);
+ CipherUtility.processStreams(cipher, bcis, bcos);
} catch (Exception e) {
throw new ProcessException(e);
}
+
+ // Update the attributes in the temporary holder
+ flowfileAttributes.putAll(writeAttributes(encryptionMethod, KeyDerivationFunction.NONE, iv, bcis, bcos, false));
}
}
@@ -149,14 +179,22 @@ public class KeyedEncryptor implements Encryptor {
// Initialize cipher provider
KeyedCipherProvider cipherProvider = (KeyedCipherProvider) CipherProviderFactory.getCipherProvider(KeyDerivationFunction.NONE);
+ // Wrap the streams for byte counting if necessary
+ ByteCountingInputStream bcis = CipherUtility.wrapStreamForCounting(in);
+ ByteCountingOutputStream bcos = CipherUtility.wrapStreamForCounting(out);
+
// Generate cipher
+ Cipher cipher;
try {
- Cipher cipher = cipherProvider.getCipher(encryptionMethod, key, iv, true);
- cipherProvider.writeIV(cipher.getIV(), out);
- CipherUtility.processStreams(cipher, in, out);
+ cipher = cipherProvider.getCipher(encryptionMethod, key, iv, true);
+ cipherProvider.writeIV(cipher.getIV(), bcos);
+ CipherUtility.processStreams(cipher, bcis, bcos);
} catch (Exception e) {
throw new ProcessException(e);
}
+
+ // Update the attributes in the temporary holder
+ flowfileAttributes.putAll(writeAttributes(encryptionMethod, KeyDerivationFunction.NONE, cipher.getIV(), bcis, bcos, true));
}
}
}
\ No newline at end of file
diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/OpenPGPKeyBasedEncryptor.java b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/OpenPGPKeyBasedEncryptor.java
index 6b5e780..a50f9ba 100644
--- a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/OpenPGPKeyBasedEncryptor.java
+++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/OpenPGPKeyBasedEncryptor.java
@@ -27,6 +27,7 @@ import java.security.NoSuchProviderException;
import java.security.SecureRandom;
import java.util.Date;
import java.util.Iterator;
+import java.util.Map;
import java.util.zip.Deflater;
import org.apache.nifi.processor.exception.ProcessException;
import org.apache.nifi.processor.io.StreamCallback;
@@ -86,6 +87,11 @@ public class OpenPGPKeyBasedEncryptor implements Encryptor {
}
@Override
+ public void updateAttributes(Map<String, String> attributes) throws ProcessException {
+ // TODO: Implement
+ }
+
+ @Override
public StreamCallback getEncryptionCallback() throws Exception {
return new OpenPGPEncryptCallback(algorithm, cipher, provider, keyring, userId, filename);
}
diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/OpenPGPPasswordBasedEncryptor.java b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/OpenPGPPasswordBasedEncryptor.java
index 1e843a7..524f839 100644
--- a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/OpenPGPPasswordBasedEncryptor.java
+++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/OpenPGPPasswordBasedEncryptor.java
@@ -21,6 +21,7 @@ import static org.bouncycastle.openpgp.PGPUtil.getDecoderStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.util.Map;
import org.apache.nifi.processor.exception.ProcessException;
import org.apache.nifi.processor.io.StreamCallback;
import org.apache.nifi.processors.standard.EncryptContent.Encryptor;
@@ -66,6 +67,11 @@ public class OpenPGPPasswordBasedEncryptor implements Encryptor {
return new OpenPGPDecryptCallback(provider, password);
}
+ @Override
+ public void updateAttributes(Map<String, String> attributes) throws ProcessException {
+ // TODO: Implement
+ }
+
private static class OpenPGPDecryptCallback implements StreamCallback {
private String provider;
diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/PasswordBasedEncryptor.java b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/PasswordBasedEncryptor.java
index 4ba8fb3..54e12cc 100644
--- a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/PasswordBasedEncryptor.java
+++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/security/util/crypto/PasswordBasedEncryptor.java
@@ -16,21 +16,31 @@
*/
package org.apache.nifi.security.util.crypto;
+import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
+import java.util.Map;
+import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.spec.PBEKeySpec;
+import org.apache.commons.codec.binary.Hex;
import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.processor.exception.ProcessException;
import org.apache.nifi.processor.io.StreamCallback;
-import org.apache.nifi.processors.standard.EncryptContent.Encryptor;
+import org.apache.nifi.processors.standard.EncryptContent;
import org.apache.nifi.security.util.EncryptionMethod;
import org.apache.nifi.security.util.KeyDerivationFunction;
+import org.apache.nifi.stream.io.ByteCountingInputStream;
+import org.apache.nifi.stream.io.ByteCountingOutputStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
-public class PasswordBasedEncryptor implements Encryptor {
+public class PasswordBasedEncryptor extends AbstractEncryptor {
+ private static final Logger logger = LoggerFactory.getLogger(PasswordBasedEncryptor.class);
private EncryptionMethod encryptionMethod;
private PBEKeySpec password;
@@ -39,18 +49,6 @@ public class PasswordBasedEncryptor implements Encryptor {
private static final int DEFAULT_MAX_ALLOWED_KEY_LENGTH = 128;
private static final int MINIMUM_SAFE_PASSWORD_LENGTH = 10;
- private static boolean isUnlimitedStrengthCryptographyEnabled;
-
- // Evaluate an unlimited strength algorithm to determine if we support the capability we have on the system
- static {
- try {
- isUnlimitedStrengthCryptographyEnabled = (Cipher.getMaxAllowedKeyLength("AES") > DEFAULT_MAX_ALLOWED_KEY_LENGTH);
- } catch (NoSuchAlgorithmException e) {
- // if there are issues with this, we default back to the value established
- isUnlimitedStrengthCryptographyEnabled = false;
- }
- }
-
public PasswordBasedEncryptor(final EncryptionMethod encryptionMethod, final char[] password, KeyDerivationFunction kdf) {
super();
try {
@@ -93,8 +91,21 @@ public class PasswordBasedEncryptor implements Encryptor {
return MINIMUM_SAFE_PASSWORD_LENGTH;
}
- public static boolean supportsUnlimitedStrength() {
- return isUnlimitedStrengthCryptographyEnabled;
+ static Map<String, String> writeAttributes(EncryptionMethod encryptionMethod,
+ KeyDerivationFunction kdf, byte[] iv, byte[] kdfSalt, ByteCountingInputStream bcis, ByteCountingOutputStream bcos, boolean encryptMode) {
+ Map<String, String> attributes = AbstractEncryptor.writeAttributes(encryptionMethod, kdf, iv, bcis, bcos, encryptMode);
+
+ if (kdf.hasFormattedSalt()) {
+ final String saltString = new String(kdfSalt, StandardCharsets.UTF_8);
+ attributes.put(EncryptContent.KDF_SALT_ATTR, saltString);
+ attributes.put(EncryptContent.KDF_SALT_LEN_ATTR, String.valueOf(saltString.length()));
+ }
+
+ byte[] rawSalt = CipherUtility.extractRawSalt(kdfSalt, kdf);
+ attributes.put(EncryptContent.SALT_ATTR, Hex.encodeHexString(rawSalt));
+ attributes.put(EncryptContent.SALT_LEN_ATTR, String.valueOf(rawSalt.length));
+
+ return attributes;
}
@Override
@@ -110,6 +121,11 @@ public class PasswordBasedEncryptor implements Encryptor {
@SuppressWarnings("deprecation")
private class DecryptCallback implements StreamCallback {
+ private static final boolean DECRYPT = false;
+
+ // Limit retry mechanism to 10 MiB; more than that is wasteful
+ private static final int RETRY_LIMIT_LENGTH = 10 * 1024 * 1024;
+
public DecryptCallback() {
}
@@ -118,14 +134,18 @@ public class PasswordBasedEncryptor implements Encryptor {
// Initialize cipher provider
PBECipherProvider cipherProvider = (PBECipherProvider) CipherProviderFactory.getCipherProvider(kdf);
+ // Wrap the streams for byte counting if necessary
+ ByteCountingInputStream bcis = CipherUtility.wrapStreamForCounting(in);
+ ByteCountingOutputStream bcos = CipherUtility.wrapStreamForCounting(out);
+
// Read salt
byte[] salt;
try {
// NiFi legacy code determined the salt length based on the cipher block size
if (cipherProvider instanceof org.apache.nifi.security.util.crypto.NiFiLegacyCipherProvider) {
- salt = ((org.apache.nifi.security.util.crypto.NiFiLegacyCipherProvider) cipherProvider).readSalt(encryptionMethod, in);
+ salt = ((org.apache.nifi.security.util.crypto.NiFiLegacyCipherProvider) cipherProvider).readSalt(encryptionMethod, bcis);
} else {
- salt = cipherProvider.readSalt(in);
+ salt = cipherProvider.readSalt(bcis);
}
} catch (final EOFException e) {
throw new ProcessException("Cannot decrypt because file size is smaller than salt size", e);
@@ -137,24 +157,103 @@ public class PasswordBasedEncryptor implements Encryptor {
// Generate cipher
try {
Cipher cipher;
+ final String password = new String(PasswordBasedEncryptor.this.password.getPassword());
// Read IV if necessary
+ byte[] iv = new byte[0];
if (cipherProvider instanceof RandomIVPBECipherProvider) {
RandomIVPBECipherProvider rivpcp = (RandomIVPBECipherProvider) cipherProvider;
- byte[] iv = rivpcp.readIV(in);
- cipher = rivpcp.getCipher(encryptionMethod, new String(password.getPassword()), salt, iv, keyLength, false);
+ iv = rivpcp.readIV(bcis);
+ cipher = rivpcp.getCipher(encryptionMethod, password, salt, iv, keyLength, DECRYPT);
+ } else {
+ cipher = cipherProvider.getCipher(encryptionMethod, password, salt, keyLength, DECRYPT);
+ }
+
+ // Bcrypt has multiple versions of KDF which can cause BC problems
+ if (kdf == KeyDerivationFunction.BCRYPT) {
+ final boolean cipherProviderIsSafeType = cipherProvider instanceof BcryptCipherProvider;
+ logger.debug("Cipher provider instance of BcryptCipherProvider: {}", cipherProviderIsSafeType);
+ if (!cipherProviderIsSafeType) {
+ throw new ProcessException("The KDF is Bcrypt but the cipher provider is not a Bcrypt cipher provider; " + cipherProvider.getClass().getSimpleName());
+ }
+ handleBcryptDecryption((BcryptCipherProvider) cipherProvider, bcis, bcos, salt, keyLength, cipher, iv, password);
} else {
- cipher = cipherProvider.getCipher(encryptionMethod, new String(password.getPassword()), salt, keyLength, false);
+ // For any other KDF, only attempt decryption once
+ CipherUtility.processStreams(cipher, bcis, bcos);
}
- CipherUtility.processStreams(cipher, in, out);
+
+ // Update the attributes in the temporary holder
+ flowfileAttributes.putAll(writeAttributes(encryptionMethod, kdf, cipher.getIV(), salt, bcis, bcos, DECRYPT));
} catch (Exception e) {
throw new ProcessException(e);
}
}
+
+ /**
+ * Handles the {@code Bcrypt} decryption separately, as the Bcrypt key derivation process changed during NiFi 1.12.0
+ * and some cipher texts encrypted with a legacy key may need to be decrypted. This method attempts to decrypt normally,
+ * and if certain conditions are met (the cipher text is under 10 MiB and the failure was specifically due to the wrong
+ * key), the legacy key derivation is used to make a second attempt.
+ *
+ * @param bcryptCipherProvider the cipher provider
+ * @param bcis the input stream (cipher text)
+ * @param bcos the output stream to write the plaintext to
+ * @param salt the salt
+ * @param keyLength the key length to derive in bits
+ * @param cipher the initial cipher object
+ * @param iv the IV
+ * @param password the password
+ * @throws IOException if there is a problem reading/writing to the streams
+ * @throws ProcessException if there is any other exception
+ */
+ protected void handleBcryptDecryption(BcryptCipherProvider bcryptCipherProvider, ByteCountingInputStream bcis,
+ ByteCountingOutputStream bcos, byte[] salt, int keyLength, Cipher cipher,
+ byte[] iv, String password)
+ throws IOException, ProcessException {
+ // Attempt the decryption and if it fails due to pad block exception (wrong key), derive key using legacy process and attempt to decrypt again
+ bcis.mark(RETRY_LIMIT_LENGTH);
+
+ try {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ CipherUtility.processStreams(cipher, bcis, baos);
+ bcos.write(baos.toByteArray());
+ } catch (ProcessException e) {
+ // All exceptions from processStreams are wrapped in ProcessException, so check the underlying cause
+ if (shouldAttemptLegacyDecrypt(e, bcis.getBytesConsumed())) {
+ logger.warn("Error decrypting content using Bcrypt-derived key; attempting legacy key derivation for backward compatibility");
+ bcis.reset();
+ cipher = bcryptCipherProvider.getLegacyDecryptCipher(encryptionMethod, password, salt, iv, keyLength);
+ CipherUtility.processStreams(cipher, bcis, bcos);
+ } else {
+ // Some other exception or too much content to retry
+ throw e;
+ }
+ }
+ }
+
+ /**
+ * Returns {@code true} if the Bcrypt decryption failed for reasons that might be resolved by attempting a second
+ * decryption using the legacy key derivation process.
+ *
+ * @param e the exception thrown during the initial decryption attempt
+ * @param bytesConsumed the number of bytes consumed from the cipher text stream
+ * @return true if the legacy key derivation process should be attempted
+ */
+ protected boolean shouldAttemptLegacyDecrypt(ProcessException e, long bytesConsumed) {
+ final boolean causeIsWrongKey = e.getCause() instanceof BadPaddingException;
+ final boolean bytesConsumedWithinLimit = bytesConsumed < RETRY_LIMIT_LENGTH;
+ if (logger.isDebugEnabled()) {
+ logger.debug("Exception cause instance of BadPaddingException: {}", causeIsWrongKey);
+ logger.debug("Bytes consumed ({} B) < retry limit ({} B): {}", bytesConsumed, RETRY_LIMIT_LENGTH, bytesConsumedWithinLimit);
+ }
+ return causeIsWrongKey && bytesConsumedWithinLimit;
+ }
}
@SuppressWarnings("deprecation")
private class EncryptCallback implements StreamCallback {
+ private static final boolean ENCRYPT = true;
+
public EncryptCallback() {
}
@@ -162,6 +261,9 @@ public class PasswordBasedEncryptor implements Encryptor {
public void process(final InputStream in, final OutputStream out) throws IOException {
// Initialize cipher provider
PBECipherProvider cipherProvider = (PBECipherProvider) CipherProviderFactory.getCipherProvider(kdf);
+ if (kdf == KeyDerivationFunction.PBKDF2) {
+ flowfileAttributes.put("encryptcontent.pbkdf2_iterations", String.valueOf(((PBKDF2CipherProvider) cipherProvider).getIterationCount()));
+ }
// Generate salt
byte[] salt;
@@ -172,20 +274,28 @@ public class PasswordBasedEncryptor implements Encryptor {
salt = cipherProvider.generateSalt();
}
+ // Wrap the streams for byte counting if necessary
+ ByteCountingInputStream bcis = CipherUtility.wrapStreamForCounting(in);
+ ByteCountingOutputStream bcos = CipherUtility.wrapStreamForCounting(out);
+
// Write to output stream
- cipherProvider.writeSalt(salt, out);
+ cipherProvider.writeSalt(salt, bcos);
// Determine necessary key length
int keyLength = CipherUtility.parseKeyLengthFromAlgorithm(encryptionMethod.getAlgorithm());
// Generate cipher
try {
- Cipher cipher = cipherProvider.getCipher(encryptionMethod, new String(password.getPassword()), salt, keyLength, true);
+ Cipher cipher = cipherProvider.getCipher(encryptionMethod, new String(password.getPassword()), salt, keyLength, ENCRYPT);
+
// Write IV if necessary
if (cipherProvider instanceof RandomIVPBECipherProvider) {
- ((RandomIVPBECipherProvider) cipherProvider).writeIV(cipher.getIV(), out);
+ ((RandomIVPBECipherProvider) cipherProvider).writeIV(cipher.getIV(), bcos);
}
- CipherUtility.processStreams(cipher, in, out);
+ CipherUtility.processStreams(cipher, bcis, bcos);
+
+ // Update the attributes in the temporary holder
+ flowfileAttributes.putAll(writeAttributes(encryptionMethod, kdf, cipher.getIV(), salt, bcis, bcos, ENCRYPT));
} catch (Exception e) {
throw new ProcessException(e);
}
diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/TestEncryptContentGroovy.groovy b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/TestEncryptContentGroovy.groovy
index ca908a1..1e7d6f9 100644
--- a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/TestEncryptContentGroovy.groovy
+++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/processors/standard/TestEncryptContentGroovy.groovy
@@ -16,11 +16,17 @@
*/
package org.apache.nifi.processors.standard
+import groovy.time.TimeCategory
+import groovy.time.TimeDuration
+import org.apache.commons.codec.binary.Hex
import org.apache.nifi.components.ValidationResult
import org.apache.nifi.security.util.EncryptionMethod
import org.apache.nifi.security.util.KeyDerivationFunction
+import org.apache.nifi.security.util.crypto.Argon2CipherProvider
+import org.apache.nifi.security.util.crypto.Argon2SecureHasher
import org.apache.nifi.security.util.crypto.CipherUtility
import org.apache.nifi.security.util.crypto.PasswordBasedEncryptor
+import org.apache.nifi.security.util.crypto.RandomIVPBECipherProvider
import org.apache.nifi.util.MockFlowFile
import org.apache.nifi.util.MockProcessContext
import org.apache.nifi.util.TestRunner
@@ -37,8 +43,13 @@ import org.junit.runners.JUnit4
import org.slf4j.Logger
import org.slf4j.LoggerFactory
+import javax.crypto.Cipher
+import java.nio.charset.StandardCharsets
import java.nio.file.Paths
import java.security.Security
+import java.text.SimpleDateFormat
+import java.time.Instant
+import java.time.temporal.ChronoUnit
@RunWith(JUnit4.class)
class TestEncryptContentGroovy {
@@ -68,7 +79,7 @@ class TestEncryptContentGroovy {
void testShouldValidateMaxKeySizeForAlgorithmsOnUnlimitedStrengthJVM() throws IOException {
// Arrange
Assume.assumeTrue("Test is being skipped due to this JVM lacking JCE Unlimited Strength Jurisdiction Policy file.",
- PasswordBasedEncryptor.supportsUnlimitedStrength())
+ CipherUtility.isUnlimitedStrengthCryptoSupported())
final TestRunner runner = TestRunners.newTestRunner(EncryptContent.class)
Collection<ValidationResult> results
@@ -106,7 +117,7 @@ class TestEncryptContentGroovy {
void testShouldValidateMaxKeySizeForAlgorithmsOnLimitedStrengthJVM() throws IOException {
// Arrange
Assume.assumeTrue("Test is being skipped because this JVM supports unlimited strength crypto.",
- !PasswordBasedEncryptor.supportsUnlimitedStrength())
+ !CipherUtility.isUnlimitedStrengthCryptoSupported())
final TestRunner runner = TestRunners.newTestRunner(EncryptContent.class)
Collection<ValidationResult> results
@@ -199,12 +210,14 @@ class TestEncryptContentGroovy {
logger.info("Trying encryption method ${encryptionMethod.name()}")
runner.setProperty(EncryptContent.ENCRYPTION_ALGORITHM, encryptionMethod.name())
+ // Scenario 1: Legacy KDF + keyed cipher -> validation error
final def INVALID_KDFS = [KeyDerivationFunction.NIFI_LEGACY, KeyDerivationFunction.OPENSSL_EVP_BYTES_TO_KEY]
INVALID_KDFS.each { KeyDerivationFunction invalidKDF ->
logger.info("Trying KDF ${invalidKDF.name()}")
runner.setProperty(EncryptContent.KEY_DERIVATION_FUNCTION, invalidKDF.name())
runner.setProperty(EncryptContent.RAW_KEY_HEX, VALID_KEY_HEX)
+ runner.removeProperty(EncryptContent.PASSWORD)
runner.enqueue(new byte[0])
pc = (MockProcessContext) runner.getProcessContext()
@@ -213,22 +226,41 @@ class TestEncryptContentGroovy {
results = pc.validate()
// Assert
- Assert.assertEquals(1, results.size())
logger.expected(results)
+ assert results.size() == 1
ValidationResult keyLengthInvalidVR = results.first()
- String expectedResult = "'key-derivation-function' is invalid because Key Derivation Function is required to be NONE, BCRYPT, SCRYPT, PBKDF2 when using " +
+ String expectedResult = "'key-derivation-function' is invalid because Key Derivation Function is required to be BCRYPT, SCRYPT, PBKDF2, ARGON2, NONE when using " +
"algorithm ${encryptionMethod.algorithm}"
String message = "'" + keyLengthInvalidVR.toString() + "' contains '" + expectedResult + "'"
- Assert.assertTrue(message, keyLengthInvalidVR.toString().contains(expectedResult))
+ assert keyLengthInvalidVR.toString().contains(expectedResult)
}
- final
- def VALID_KDFS = [KeyDerivationFunction.NONE, KeyDerivationFunction.BCRYPT, KeyDerivationFunction.SCRYPT, KeyDerivationFunction.PBKDF2]
+ // Scenario 2: No KDF + keyed cipher + raw-key-hex -> valid
+ def none = KeyDerivationFunction.NONE
+ logger.info("Trying KDF ${none.name()}")
+
+ runner.setProperty(EncryptContent.KEY_DERIVATION_FUNCTION, none.name())
+ runner.setProperty(EncryptContent.RAW_KEY_HEX, VALID_KEY_HEX)
+ runner.removeProperty(EncryptContent.PASSWORD)
+
+ runner.enqueue(new byte[0])
+ pc = (MockProcessContext) runner.getProcessContext()
+
+ // Act
+ results = pc.validate()
+
+ // Assert
+ assert results.isEmpty()
+
+ // Scenario 3: Strong KDF + keyed cipher + password -> valid
+ final def VALID_KDFS = [KeyDerivationFunction.BCRYPT, KeyDerivationFunction.SCRYPT, KeyDerivationFunction.PBKDF2, KeyDerivationFunction.ARGON2]
VALID_KDFS.each { KeyDerivationFunction validKDF ->
logger.info("Trying KDF ${validKDF.name()}")
runner.setProperty(EncryptContent.KEY_DERIVATION_FUNCTION, validKDF.name())
+ runner.setProperty(EncryptContent.PASSWORD, "thisIsABadPassword")
+ runner.removeProperty(EncryptContent.RAW_KEY_HEX)
runner.enqueue(new byte[0])
pc = (MockProcessContext) runner.getProcessContext()
@@ -237,7 +269,103 @@ class TestEncryptContentGroovy {
results = pc.validate()
// Assert
- Assert.assertEquals(0, results.size())
+ assert results.isEmpty()
+ }
+ }
+ }
+
+ @Test
+ void testKDFShouldDefaultToNone() {
+ // Arrange
+ final TestRunner runner = TestRunners.newTestRunner(EncryptContent.class)
+ Collection<ValidationResult> results
+ MockProcessContext pc
+
+ runner.enqueue(new byte[0])
+ pc = (MockProcessContext) runner.getProcessContext()
+
+ // Act
+ String defaultKDF = pc.getProperty("key-derivation-function").getValue()
+
+ // Assert
+ assert defaultKDF == KeyDerivationFunction.NONE.name()
+ }
+
+ @Test
+ void testEMShouldDefaultToAES_GCM() {
+ // Arrange
+ final TestRunner runner = TestRunners.newTestRunner(EncryptContent.class)
+ Collection<ValidationResult> results
+ MockProcessContext pc
+
+ runner.enqueue(new byte[0])
+ pc = (MockProcessContext) runner.getProcessContext()
+
+ // Act
+ String defaultEM = pc.getProperty("Encryption Algorithm").getValue()
+
+ // Assert
+ assert defaultEM == EncryptionMethod.AES_GCM.name()
+ }
+
+ @Test
+ void testShouldValidateKeyMaterialSourceWhenKeyedCipherSelected() {
+ // Arrange
+ final TestRunner runner = TestRunners.newTestRunner(EncryptContent.class)
+ Collection<ValidationResult> results
+ MockProcessContext pc
+
+ def keyedEncryptionMethods = EncryptionMethod.values().findAll { it.isKeyedCipher() }
+ logger.info("Testing keyed encryption methods: ${keyedEncryptionMethods*.name()}")
+
+ final int VALID_KEY_LENGTH = 128
+ final String VALID_KEY_HEX = "ab" * (VALID_KEY_LENGTH / 8)
+ logger.info("Using key ${VALID_KEY_HEX} (${VALID_KEY_HEX.length() * 4} bits)")
+
+ final String VALID_PASSWORD = "thisIsABadPassword"
+ logger.info("Using password ${VALID_PASSWORD} (${VALID_PASSWORD.length()} bytes)")
+
+ runner.setProperty(EncryptContent.MODE, EncryptContent.ENCRYPT_MODE)
+ KeyDerivationFunction none = KeyDerivationFunction.NONE
+ final def VALID_KDFS = KeyDerivationFunction.values().findAll { it.isStrongKDF() }
+
+ // Scenario 1 - RKH w/ KDF NONE & em in [CBC, CTR, GCM] (no password)
+ keyedEncryptionMethods.each { EncryptionMethod kem ->
+ logger.info("Trying encryption method ${kem.name()} with KDF ${none.name()}")
+ runner.setProperty(EncryptContent.ENCRYPTION_ALGORITHM, kem.name())
+ runner.setProperty(EncryptContent.KEY_DERIVATION_FUNCTION, none.name())
+
+ logger.info("Setting raw key hex: ${VALID_KEY_HEX}")
+ runner.setProperty(EncryptContent.RAW_KEY_HEX, VALID_KEY_HEX)
+ runner.removeProperty(EncryptContent.PASSWORD)
+
+ runner.enqueue(new byte[0])
+ pc = (MockProcessContext) runner.getProcessContext()
+
+ // Act
+ results = pc.validate()
+
+ // Assert
+ assert results.isEmpty()
+
+ // Scenario 2 - PW w/ KDF in [BCRYPT, SCRYPT, PBKDF2, ARGON2] & em in [CBC, CTR, GCM] (no RKH)
+ VALID_KDFS.each { KeyDerivationFunction kdf ->
+ logger.info("Trying encryption method ${kem.name()} with KDF ${kdf.name()}")
+ runner.setProperty(EncryptContent.ENCRYPTION_ALGORITHM, kem.name())
+ runner.setProperty(EncryptContent.KEY_DERIVATION_FUNCTION, kdf.name())
+
+ logger.info("Setting password: ${VALID_PASSWORD}")
+ runner.removeProperty(EncryptContent.RAW_KEY_HEX)
+ runner.setProperty(EncryptContent.PASSWORD, VALID_PASSWORD)
+
+ runner.enqueue(new byte[0])
+ pc = (MockProcessContext) runner.getProcessContext()
+
+ // Act
+ results = pc.validate()
+
+ // Assert
+ assert results.isEmpty()
}
}
}
@@ -251,7 +379,7 @@ class TestEncryptContentGroovy {
final String PASSWORD = "short"
def encryptionMethods = EncryptionMethod.values().findAll { it.algorithm.startsWith("PBE") }
- if (!PasswordBasedEncryptor.supportsUnlimitedStrength()) {
+ if (!CipherUtility.isUnlimitedStrengthCryptoSupported()) {
// Remove all unlimited strength algorithms
encryptionMethods.removeAll { it.unlimitedStrength }
}
@@ -264,8 +392,7 @@ class TestEncryptContentGroovy {
logger.info("Trying encryption method ${encryptionMethod.name()}")
runner.setProperty(EncryptContent.ENCRYPTION_ALGORITHM, encryptionMethod.name())
- final
- def INVALID_KDFS = [KeyDerivationFunction.NONE, KeyDerivationFunction.BCRYPT, KeyDerivationFunction.SCRYPT, KeyDerivationFunction.PBKDF2]
+ final def INVALID_KDFS = [KeyDerivationFunction.NONE, KeyDerivationFunction.BCRYPT, KeyDerivationFunction.SCRYPT, KeyDerivationFunction.PBKDF2, KeyDerivationFunction.ARGON2]
INVALID_KDFS.each { KeyDerivationFunction invalidKDF ->
logger.info("Trying KDF ${invalidKDF.name()}")
@@ -342,10 +469,341 @@ class TestEncryptContentGroovy {
}
}
+ // TODO: Implement
+ @Test
+ void testArgon2EncryptionShouldWriteAttributesWithEncryptionMetadata() throws IOException {
+ // Arrange
+ final TestRunner testRunner = TestRunners.newTestRunner(new EncryptContent())
+ KeyDerivationFunction kdf = KeyDerivationFunction.ARGON2
+ EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
+ logger.info("Attempting encryption with {}", encryptionMethod.name())
+
+ testRunner.setProperty(EncryptContent.PASSWORD, "thisIsABadPassword")
+ testRunner.setProperty(EncryptContent.KEY_DERIVATION_FUNCTION, kdf.name())
+ testRunner.setProperty(EncryptContent.ENCRYPTION_ALGORITHM, encryptionMethod.name())
+ testRunner.setProperty(EncryptContent.MODE, EncryptContent.ENCRYPT_MODE)
+
+ String PLAINTEXT = "This is a plaintext message. "
+
+ // Act
+ testRunner.enqueue(PLAINTEXT)
+ testRunner.clearTransferState()
+ testRunner.run()
+
+ // Assert
+ testRunner.assertAllFlowFilesTransferred(EncryptContent.REL_SUCCESS, 1)
+ logger.info("Successfully encrypted with {}", encryptionMethod.name())
+
+ MockFlowFile flowFile = testRunner.getFlowFilesForRelationship(EncryptContent.REL_SUCCESS).get(0)
+ testRunner.assertQueueEmpty()
+
+ printFlowFileAttributes(flowFile.getAttributes())
+
+ byte[] flowfileContentBytes = flowFile.getData()
+ String flowfileContent = flowFile.getContent()
+
+ int ivDelimiterStart = CipherUtility.findSequence(flowfileContentBytes, RandomIVPBECipherProvider.IV_DELIMITER)
+ logger.info("IV delimiter starts at ${ivDelimiterStart}")
+
+ final byte[] EXPECTED_KDF_SALT_BYTES = extractFullSaltFromCipherBytes(flowfileContentBytes)
+ final String EXPECTED_KDF_SALT = new String(EXPECTED_KDF_SALT_BYTES)
+ final String EXPECTED_SALT_HEX = extractRawSaltHexFromFullSalt(EXPECTED_KDF_SALT_BYTES, kdf)
+ logger.info("Extracted expected raw salt (hex): ${EXPECTED_SALT_HEX}")
+
+ final String EXPECTED_IV_HEX = Hex.encodeHexString(flowfileContentBytes[(ivDelimiterStart - 16)..<ivDelimiterStart] as byte[])
+
+ printFlowFileAttributes(flowFile.getAttributes())
+
+ // Assert the timestamp attribute was written and is accurate
+ def diff = calculateTimestampDifference(new Date(), flowFile.getAttribute("encryptcontent.timestamp"))
+ assert diff.toMilliseconds() < 1_000
+ assert flowFile.getAttribute("encryptcontent.algorithm") == encryptionMethod.name()
+ assert flowFile.getAttribute("encryptcontent.kdf") == kdf.name()
+ assert flowFile.getAttribute("encryptcontent.action") == "encrypted"
+ assert flowFile.getAttribute("encryptcontent.salt") == EXPECTED_SALT_HEX
+ assert flowFile.getAttribute("encryptcontent.salt_length") == "16"
+ assert flowFile.getAttribute("encryptcontent.kdf_salt") == EXPECTED_KDF_SALT
+ assert (29..54)*.toString().contains(flowFile.getAttribute("encryptcontent.kdf_salt_length"))
+ assert flowFile.getAttribute("encryptcontent.iv") == EXPECTED_IV_HEX
+ assert flowFile.getAttribute("encryptcontent.iv_length") == "16"
+ assert flowFile.getAttribute("encryptcontent.plaintext_length") == PLAINTEXT.size() as String
+ assert flowFile.getAttribute("encryptcontent.cipher_text_length") == flowfileContentBytes.size() as String
+ }
+
+ static void printFlowFileAttributes(Map<String, String> attributes) {
+ int maxLength = attributes.keySet()*.length().max()
+ attributes.sort().each { attr, value ->
+ logger.info("Attribute: ${attr.padRight(maxLength)}: ${value}")
+ }
+ }
+
+ @Test
+ void testKeyedEncryptionShouldWriteAttributesWithEncryptionMetadata() throws IOException {
+ // Arrange
+ final TestRunner testRunner = TestRunners.newTestRunner(new EncryptContent())
+ KeyDerivationFunction kdf = KeyDerivationFunction.NONE
+ EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
+ logger.info("Attempting encryption with {}", encryptionMethod.name())
+
+ testRunner.setProperty(EncryptContent.RAW_KEY_HEX, "0123456789ABCDEFFEDCBA9876543210")
+ testRunner.setProperty(EncryptContent.KEY_DERIVATION_FUNCTION, kdf.name())
+ testRunner.setProperty(EncryptContent.ENCRYPTION_ALGORITHM, encryptionMethod.name())
+ testRunner.setProperty(EncryptContent.MODE, EncryptContent.ENCRYPT_MODE)
+
+ String PLAINTEXT = "This is a plaintext message. "
+
+ // Act
+ testRunner.enqueue(PLAINTEXT)
+ testRunner.clearTransferState()
+ testRunner.run()
+
+ // Assert
+ testRunner.assertAllFlowFilesTransferred(EncryptContent.REL_SUCCESS, 1)
+ logger.info("Successfully encrypted with {}", encryptionMethod.name())
+
+ MockFlowFile flowFile = testRunner.getFlowFilesForRelationship(EncryptContent.REL_SUCCESS).get(0)
+ testRunner.assertQueueEmpty()
+
+ printFlowFileAttributes(flowFile.getAttributes())
+
+ byte[] flowfileContentBytes = flowFile.getData()
+ String flowfileContent = flowFile.getContent()
+ logger.info("Cipher text (${flowfileContentBytes.length}): ${Hex.encodeHexString(flowfileContentBytes)}")
+
+ int ivDelimiterStart = CipherUtility.findSequence(flowfileContentBytes, RandomIVPBECipherProvider.IV_DELIMITER)
+ logger.info("IV delimiter starts at ${ivDelimiterStart}")
+ assert ivDelimiterStart == 16
+
+ def diff = calculateTimestampDifference(new Date(), flowFile.getAttribute("encryptcontent.timestamp"))
+ logger.info("Timestamp difference: ${diff}")
+
+ // Assert the timestamp attribute was written and is accurate
+ assert diff.toMilliseconds() < 1_000
+
+ final String EXPECTED_IV_HEX = Hex.encodeHexString(flowfileContentBytes[0..<ivDelimiterStart] as byte[])
+ final int EXPECTED_CIPHER_TEXT_LENGTH = CipherUtility.calculateCipherTextLength(PLAINTEXT.size(), 0)
+
+ assert flowFile.getAttribute("encryptcontent.algorithm") == encryptionMethod.name()
+ assert flowFile.getAttribute("encryptcontent.kdf") == kdf.name()
+ assert flowFile.getAttribute("encryptcontent.action") == "encrypted"
+ assert flowFile.getAttribute("encryptcontent.iv") == EXPECTED_IV_HEX
+ assert flowFile.getAttribute("encryptcontent.iv_length") == "16"
+ assert flowFile.getAttribute("encryptcontent.plaintext_length") == PLAINTEXT.size() as String
+ assert flowFile.getAttribute("encryptcontent.cipher_text_length") == EXPECTED_CIPHER_TEXT_LENGTH as String
+ }
+
+ @Test
+ void testKeyedDecryptionShouldWriteAttributesWithEncryptionMetadata() throws IOException {
+ // Arrange
+ final TestRunner testRunner = TestRunners.newTestRunner(new EncryptContent())
+ KeyDerivationFunction kdf = KeyDerivationFunction.NONE
+ EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
+ logger.info("Attempting decryption with {}", encryptionMethod.name())
+
+ testRunner.setProperty(EncryptContent.RAW_KEY_HEX, "0123456789ABCDEFFEDCBA9876543210")
+ testRunner.setProperty(EncryptContent.KEY_DERIVATION_FUNCTION, kdf.name())
+ testRunner.setProperty(EncryptContent.ENCRYPTION_ALGORITHM, encryptionMethod.name())
+ testRunner.setProperty(EncryptContent.MODE, EncryptContent.ENCRYPT_MODE)
+
+ String PLAINTEXT = "This is a plaintext message. "
+
+ testRunner.enqueue(PLAINTEXT)
+ testRunner.clearTransferState()
+ testRunner.run()
+
+ MockFlowFile encryptedFlowFile = testRunner.getFlowFilesForRelationship(EncryptContent.REL_SUCCESS).first()
+ byte[] cipherText = encryptedFlowFile.getData()
+
+ int ivDelimiterStart = CipherUtility.findSequence(cipherText, RandomIVPBECipherProvider.IV_DELIMITER)
+ logger.info("IV delimiter starts at ${ivDelimiterStart}")
+ assert ivDelimiterStart == 16
+ final String EXPECTED_IV_HEX = Hex.encodeHexString(cipherText[0..<ivDelimiterStart] as byte[])
+
+ testRunner.setProperty(EncryptContent.MODE, EncryptContent.DECRYPT_MODE)
+ testRunner.clearTransferState()
+ testRunner.enqueue(cipherText)
+
+ // Act
+ testRunner.run()
+
+ // Assert
+ testRunner.assertAllFlowFilesTransferred(EncryptContent.REL_SUCCESS, 1)
+ logger.info("Successfully decrypted with {}", encryptionMethod.name())
+
+ MockFlowFile flowFile = testRunner.getFlowFilesForRelationship(EncryptContent.REL_SUCCESS).get(0)
+ testRunner.assertQueueEmpty()
+
+ printFlowFileAttributes(flowFile.getAttributes())
+
+ byte[] flowfileContentBytes = flowFile.getData()
+ String flowfileContent = flowFile.getContent()
+ logger.info("Plaintext (${flowfileContentBytes.length}): ${Hex.encodeHexString(flowfileContentBytes)}")
+
+ def diff = calculateTimestampDifference(new Date(), flowFile.getAttribute("encryptcontent.timestamp"))
+ logger.info("Timestamp difference: ${diff}")
+
+ // Assert the timestamp attribute was written and is accurate
+ assert diff.toMilliseconds() < 1_000
+ assert flowFile.getAttribute("encryptcontent.algorithm") == encryptionMethod.name()
+ assert flowFile.getAttribute("encryptcontent.kdf") == kdf.name()
+ assert flowFile.getAttribute("encryptcontent.action") == "decrypted"
+ assert flowFile.getAttribute("encryptcontent.iv") == EXPECTED_IV_HEX
+ assert flowFile.getAttribute("encryptcontent.iv_length") == "16"
+ assert flowFile.getAttribute("encryptcontent.plaintext_length") == PLAINTEXT.size() as String
+ assert flowFile.getAttribute("encryptcontent.cipher_text_length") == cipherText.length as String
+ }
+
+ @Test
+ void testDifferentCompatibleConfigurations() throws IOException {
+ // Arrange
+ final TestRunner testRunner = TestRunners.newTestRunner(new EncryptContent())
+ KeyDerivationFunction argon2 = KeyDerivationFunction.ARGON2
+ EncryptionMethod aesCbcEM = EncryptionMethod.AES_CBC
+ logger.info("Attempting encryption with ${argon2} and ${aesCbcEM.name()}")
+ int keyLength = CipherUtility.parseKeyLengthFromAlgorithm(aesCbcEM.algorithm)
+
+ final String PASSWORD = "thisIsABadPassword"
+ testRunner.setProperty(EncryptContent.PASSWORD, PASSWORD)
+ testRunner.setProperty(EncryptContent.KEY_DERIVATION_FUNCTION, argon2.name())
+ testRunner.setProperty(EncryptContent.ENCRYPTION_ALGORITHM, aesCbcEM.name())
+ testRunner.setProperty(EncryptContent.MODE, EncryptContent.ENCRYPT_MODE)
+
+ String PLAINTEXT = "This is a plaintext message. "
+
+ testRunner.enqueue(PLAINTEXT)
+ testRunner.clearTransferState()
+ testRunner.run()
+
+ MockFlowFile encryptedFlowFile = testRunner.getFlowFilesForRelationship(EncryptContent.REL_SUCCESS).first()
+ byte[] fullCipherBytes = encryptedFlowFile.getData()
+ printFlowFileAttributes(encryptedFlowFile.getAttributes())
+
+ // Extract the KDF salt from the encryption metadata in the flowfile attribute
+ String argon2Salt = encryptedFlowFile.getAttribute("encryptcontent.kdf_salt")
+ Argon2SecureHasher a2sh = new Argon2SecureHasher(keyLength / 8 as int)
+ byte[] fullSaltBytes = argon2Salt.getBytes(StandardCharsets.UTF_8)
+ byte[] rawSaltBytes = Hex.decodeHex(encryptedFlowFile.getAttribute("encryptcontent.salt"))
+ byte[] keyBytes = a2sh.hashRaw(PASSWORD.getBytes(StandardCharsets.UTF_8), rawSaltBytes)
+ String keyHex = Hex.encodeHexString(keyBytes)
+ logger.sanity("Derived key bytes: ${keyHex}")
+
+ byte[] ivBytes = Hex.decodeHex(encryptedFlowFile.getAttribute("encryptcontent.iv"))
+ logger.sanity("Extracted IV bytes: ${Hex.encodeHexString(ivBytes)}")
+
+ // Sanity check the encryption
+ Argon2CipherProvider a2cp = new Argon2CipherProvider()
+ Cipher sanityCipher = a2cp.getCipher(aesCbcEM, PASSWORD, fullSaltBytes, ivBytes, CipherUtility.parseKeyLengthFromAlgorithm(aesCbcEM.algorithm), false)
+ byte[] cipherTextBytes = fullCipherBytes[-32..-1]
+ byte[] recoveredBytes = sanityCipher.doFinal(cipherTextBytes)
+ logger.sanity("Recovered text: ${new String(recoveredBytes, StandardCharsets.UTF_8)}")
+
+ // Act
+
+ // Configure decrypting processor with raw key
+ KeyDerivationFunction kdf = KeyDerivationFunction.NONE
+ EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
+ logger.info("Attempting decryption with {}", encryptionMethod.name())
+
+ testRunner.setProperty(EncryptContent.RAW_KEY_HEX, keyHex)
+ testRunner.setProperty(EncryptContent.KEY_DERIVATION_FUNCTION, kdf.name())
+ testRunner.setProperty(EncryptContent.ENCRYPTION_ALGORITHM, encryptionMethod.name())
+ testRunner.setProperty(EncryptContent.MODE, EncryptContent.DECRYPT_MODE)
+ testRunner.removeProperty(EncryptContent.PASSWORD)
+
+ testRunner.enqueue(fullCipherBytes)
+ testRunner.clearTransferState()
+ testRunner.run()
+
+ // Assert
+ testRunner.assertAllFlowFilesTransferred(EncryptContent.REL_SUCCESS, 1)
+ logger.info("Successfully decrypted with {}", encryptionMethod.name())
+
+ MockFlowFile decryptedFlowFile = testRunner.getFlowFilesForRelationship(EncryptContent.REL_SUCCESS).get(0)
+ testRunner.assertQueueEmpty()
+
+ printFlowFileAttributes(decryptedFlowFile.getAttributes())
+
+ byte[] flowfileContentBytes = decryptedFlowFile.getData()
+ logger.info("Plaintext (${flowfileContentBytes.length}): ${new String(flowfileContentBytes, StandardCharsets.UTF_8)}")
+
+ assert flowfileContentBytes == recoveredBytes
+ }
+
+ static TimeDuration calculateTimestampDifference(Date date, String timestamp) {
+ SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS Z")
+ final long dateMillis = date.toInstant().toEpochMilli()
+ logger.info("Provided timestamp ${formatter.format(date)} -> (ms): ${dateMillis}")
+ Date parsedTimestamp = formatter.parse(timestamp)
+ long parsedTimestampMillis = parsedTimestamp.toInstant().toEpochMilli()
+ logger.info("Parsed timestamp ${timestamp} -> (ms): ${parsedTimestampMillis}")
+
+ TimeCategory.minus(date, parsedTimestamp)
+ }
+
+ static byte[] extractFullSaltFromCipherBytes(byte[] cipherBytes) {
+ int saltDelimiterStart = CipherUtility.findSequence(cipherBytes, RandomIVPBECipherProvider.SALT_DELIMITER)
+ logger.info("Salt delimiter starts at ${saltDelimiterStart}")
+ byte[] saltBytes = cipherBytes[0..<saltDelimiterStart]
+ logger.info("Extracted full salt (${saltBytes.length}): ${new String(saltBytes, StandardCharsets.UTF_8)}")
+ saltBytes
+ }
+
+ static String extractRawSaltHexFromFullSalt(byte[] fullSaltBytes, KeyDerivationFunction kdf) {
+ logger.info("Full salt (${fullSaltBytes.length}): ${Hex.encodeHexString(fullSaltBytes)}")
+ // Salt will be in Base64 (or Radix64) for strong KDFs
+ byte[] rawSaltBytes = CipherUtility.extractRawSalt(fullSaltBytes, kdf)
+ logger.info("Raw salt (${rawSaltBytes.length}): ${Hex.encodeHexString(rawSaltBytes)}")
+ String rawSaltHex = Hex.encodeHexString(rawSaltBytes)
+ logger.info("Extracted expected raw salt (hex): ${rawSaltHex}")
+ rawSaltHex
+ }
+
+ @Test
+ void testShouldCompareDate() {
+ // Arrange
+ Date now = new Date()
+ logger.info("Now: ${now} -- ${now.toInstant().toEpochMilli()}")
+
+ Instant fiveSecondsLater = now.toInstant().plus(5, ChronoUnit.SECONDS)
+ Date fSLDate = Date.from(fiveSecondsLater)
+ logger.info("FSL: ${fSLDate} -- ${fiveSecondsLater.toEpochMilli()}")
+
+ // Convert entirely to String & parse back
+ Instant tenSecondsLater = fiveSecondsLater.plusMillis(5000)
+ Date tSLDate = Date.from(tenSecondsLater)
+ logger.info("TSL: ${tSLDate} -- ${tenSecondsLater.toEpochMilli()}")
+
+ // Java way ('y' is deterministic vs. 'Y' which is week-based and calendar & JVM dependent)
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS Z")
+ String tslString = sdf.format(tSLDate)
+ logger.info("TSL formatted: ${tslString}")
+
+ // Parse back to date
+ Date parsedTSLDate = sdf.parse(tslString)
+ logger.info("TSL parsed: ${parsedTSLDate} -- ${parsedTSLDate.toInstant().toEpochMilli()}")
+
+ // Act
+ def fiveSecondDiff = TimeCategory.minus(fSLDate, now)
+ logger.info(" FSL - now difference: ${fiveSecondDiff}")
+
+ def tenSecondDiff = TimeCategory.minus(tSLDate, now)
+ logger.info(" TSL - now difference: ${tenSecondDiff}")
+
+ def parsedTenSecondDiff = TimeCategory.minus(parsedTSLDate, now)
+ logger.info("PTSL - now difference: ${parsedTenSecondDiff}")
+
+ // Assert
+ assert fiveSecondDiff.seconds == 5
+ assert tenSecondDiff.seconds == 10
+ assert parsedTenSecondDiff.seconds == 10
+
+ assert [fiveSecondDiff, tenSecondDiff, parsedTenSecondDiff].every { it.days == 0 }
+ }
+
@Test
void testShouldCheckMaximumLengthOfPasswordOnLimitedStrengthCryptoJVM() throws IOException {
// Arrange
- Assume.assumeTrue("Only run on systems with limited strength crypto", !PasswordBasedEncryptor.supportsUnlimitedStrength())
+ Assume.assumeTrue("Only run on systems with limited strength crypto", !CipherUtility.isUnlimitedStrengthCryptoSupported())
final TestRunner testRunner = TestRunners.newTestRunner(new EncryptContent())
testRunner.setProperty(EncryptContent.KEY_DERIVATION_FUNCTION, KeyDerivationFunction.NIFI_LEGACY.name())
@@ -400,7 +858,7 @@ class TestEncryptContentGroovy {
def encryptionMethods = EncryptionMethod.values().findAll { it.algorithm.startsWith("PBE") }
- boolean limitedStrengthCrypto = !PasswordBasedEncryptor.supportsUnlimitedStrength()
+ boolean limitedStrengthCrypto = !CipherUtility.isUnlimitedStrengthCryptoSupported()
boolean allowWeakCrypto = false
testRunner.setProperty(EncryptContent.ALLOW_WEAK_CRYPTO, WEAK_CRYPTO_NOT_ALLOWED)
@@ -450,7 +908,7 @@ class TestEncryptContentGroovy {
def encryptionMethods = EncryptionMethod.values().findAll { it.algorithm.startsWith("PBE") }
- boolean limitedStrengthCrypto = !PasswordBasedEncryptor.supportsUnlimitedStrength()
+ boolean limitedStrengthCrypto = !CipherUtility.isUnlimitedStrengthCryptoSupported()
boolean allowWeakCrypto = true
testRunner.setProperty(EncryptContent.ALLOW_WEAK_CRYPTO, WEAK_CRYPTO_ALLOWED)
@@ -516,4 +974,44 @@ class TestEncryptContentGroovy {
// Assert
Assert.assertEquals(results.toString(), 0, results.size())
}
+
+ @Test
+ void testArgon2ShouldIncludeFullSalt() throws IOException {
+ // Arrange
+ final TestRunner testRunner = TestRunners.newTestRunner(new EncryptContent())
+ testRunner.setProperty(EncryptContent.PASSWORD, "thisIsABadPassword")
+ testRunner.setProperty(EncryptContent.KEY_DERIVATION_FUNCTION, KeyDerivationFunction.ARGON2.name())
+
+ EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
+
+ logger.info("Attempting {}", encryptionMethod.name())
+ testRunner.setProperty(EncryptContent.ENCRYPTION_ALGORITHM, encryptionMethod.name())
+ testRunner.setProperty(EncryptContent.MODE, EncryptContent.ENCRYPT_MODE)
+
+ // Act
+ testRunner.enqueue(Paths.get("src/test/resources/hello.txt"))
+ testRunner.clearTransferState()
+ testRunner.run()
+
+ // Assert
+ testRunner.assertAllFlowFilesTransferred(EncryptContent.REL_SUCCESS, 1)
+
+ MockFlowFile flowFile = testRunner.getFlowFilesForRelationship(EncryptContent.REL_SUCCESS).get(0)
+ testRunner.assertQueueEmpty()
+
+ def flowFileContent = flowFile.getContent()
+ logger.info("Flowfile content (${flowFile.getData().length}): ${Hex.encodeHexString(flowFile.getData())}")
+
+ def fullSalt = flowFileContent.substring(0, flowFileContent.indexOf(new String(RandomIVPBECipherProvider.SALT_DELIMITER, StandardCharsets.UTF_8)))
+ logger.info("Full salt (${fullSalt.size()}): ${fullSalt}")
+
+ boolean isValidFormattedSalt = Argon2CipherProvider.isArgon2FormattedSalt(fullSalt)
+ logger.info("Salt is Argon2 format: ${isValidFormattedSalt}")
+ assert isValidFormattedSalt
+
+ def FULL_SALT_LENGTH_RANGE = (49..57)
+ boolean fullSaltIsValidLength = FULL_SALT_LENGTH_RANGE.contains(fullSalt.bytes.length)
+ logger.info("Salt length (${fullSalt.length()}) in valid range (${FULL_SALT_LENGTH_RANGE})")
+ assert fullSaltIsValidLength
+ }
}
diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/KeyedEncryptorGroovyTest.groovy b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/KeyedEncryptorGroovyTest.groovy
index 0487af3..a3931eb 100644
--- a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/KeyedEncryptorGroovyTest.groovy
+++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/groovy/org/apache/nifi/security/util/crypto/KeyedEncryptorGroovyTest.groovy
@@ -19,6 +19,7 @@ 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.bouncycastle.jce.provider.BouncyCastleProvider
import org.junit.After
import org.junit.Before
@@ -29,9 +30,10 @@ import org.slf4j.LoggerFactory
import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec
+import java.nio.charset.StandardCharsets
import java.security.Security
-public class KeyedEncryptorGroovyTest {
+class KeyedEncryptorGroovyTest {
private static final Logger logger = LoggerFactory.getLogger(KeyedEncryptorGroovyTest.class)
private static final String TEST_RESOURCES_PREFIX = "src/test/resources/TestEncryptContent/"
@@ -42,7 +44,7 @@ public class KeyedEncryptorGroovyTest {
private static final SecretKey KEY = new SecretKeySpec(Hex.decodeHex(KEY_HEX as char[]), "AES")
@BeforeClass
- public static void setUpOnce() throws Exception {
+ static void setUpOnce() throws Exception {
Security.addProvider(new BouncyCastleProvider())
logger.metaClass.methodMissing = { String name, args ->
@@ -51,15 +53,15 @@ public class KeyedEncryptorGroovyTest {
}
@Before
- public void setUp() throws Exception {
+ void setUp() throws Exception {
}
@After
- public void tearDown() throws Exception {
+ void tearDown() throws Exception {
}
@Test
- public void testShouldEncryptAndDecrypt() throws Exception {
+ void testShouldEncryptAndDecrypt() throws Exception {
// Arrange
final String PLAINTEXT = "This is a plaintext message."
logger.info("Plaintext: {}", PLAINTEXT)
@@ -92,7 +94,7 @@ public class KeyedEncryptorGroovyTest {
}
@Test
- public void testShouldDecryptOpenSSLUnsaltedCipherTextWithKnownIV() throws Exception {
+ void testShouldDecryptOpenSSLUnsaltedCipherTextWithKnownIV() throws Exception {
// Arrange
final String PLAINTEXT = new File("${TEST_RESOURCES_PREFIX}/plain.txt").text
logger.info("Plaintext: {}", PLAINTEXT)
@@ -119,4 +121,74 @@ public class KeyedEncryptorGroovyTest {
logger.info("Recovered: {}", recovered)
assert PLAINTEXT.equals(recovered)
}
+
+ /**
+ * This test demonstrates that if incoming cipher text was generated by a cipher using PBE with
+ * KDF, the salt can be skipped and the cipher bytes can still be decrypted using keyed encryption.
+ * @throws Exception
+ */
+ @Test
+ void testShouldSkipSaltOnDecrypt() throws Exception {
+ final String PASSWORD = "thisIsABadPassword"
+
+ 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
+ int keyLength = CipherUtility.parseKeyLengthFromAlgorithm(encryptionMethod.algorithm)
+ logger.info("Using ${encryptionMethod.name()} with key length ${keyLength} bits")
+
+ // The PBE encryptor encrypts the data and prepends the salt and IV
+ PasswordBasedEncryptor passwordBasedEncryptor = new PasswordBasedEncryptor(EncryptionMethod.AES_CBC, PASSWORD.toCharArray(), KeyDerivationFunction.ARGON2)
+ StreamCallback encryptionCallback = passwordBasedEncryptor.getEncryptionCallback()
+ encryptionCallback.process(plainStream, cipherStream)
+
+ final byte[] cipherBytes = ((ByteArrayOutputStream) cipherStream).toByteArray()
+ logger.info("Encrypted: {}", Hex.encodeHexString(cipherBytes))
+
+ // Derive the decryption key manually from the provided salt & password
+ String kdfSalt = passwordBasedEncryptor.flowfileAttributes.get("encryptcontent.kdf_salt")
+ def costs = parseArgon2CostParamsFromSalt(kdfSalt)
+ SecureHasher secureHasher = new Argon2SecureHasher(keyLength / 8 as int, costs.m, costs.p, costs.t)
+ byte[] argon2DerivedKeyBytes = secureHasher.hashRaw(PASSWORD.getBytes(StandardCharsets.UTF_8), Argon2CipherProvider.extractRawSaltFromArgon2Salt(kdfSalt))
+ logger.sanity("Derived key bytes: ${Hex.encodeHexString(argon2DerivedKeyBytes)}")
+
+ // The keyed encryptor will attempt to decrypt the content, skipping the salt
+ KeyedEncryptor keyedEncryptor = new KeyedEncryptor(encryptionMethod, argon2DerivedKeyBytes)
+ StreamCallback decryptionCallback = keyedEncryptor.getDecryptionCallback()
+ InputStream cipherInputStream = new ByteArrayInputStream(cipherBytes)
+
+ // Act
+ 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
+ void testShouldParseCostParams() {
+ // Arrange
+ String argon2Salt = "\$argon2id\$v=19\$m=4096,t=3,p=1\$i8CIuSjrwdSuR42pb15AoQ"
+
+ // Act
+ def cost = parseArgon2CostParamsFromSalt(argon2Salt)
+ logger.info("Parsed cost: ${cost}")
+
+ // Assert
+ assert cost == [m: 4096, t: 3, p: 1]
+ }
+
+ static Map<String, Integer> parseArgon2CostParamsFromSalt(String kdfSalt) {
+ kdfSalt.tokenize("\$")[2].split(",").collectEntries {
+ def l = it.split("=")
+ [l.first(), Integer.valueOf(l.last())]
+ }
+ }
}
\ No newline at end of file
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
index 3a3f442..ce88368 100644
--- 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
@@ -18,9 +18,12 @@ 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.processors.standard.TestEncryptContentGroovy
import org.apache.nifi.security.kms.CryptoUtils
import org.apache.nifi.security.util.EncryptionMethod
import org.apache.nifi.security.util.KeyDerivationFunction
+import org.apache.nifi.stream.io.ByteCountingInputStream
+import org.apache.nifi.stream.io.ByteCountingOutputStream
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.junit.After
import org.junit.Assume
@@ -31,6 +34,8 @@ import org.slf4j.Logger
import org.slf4j.LoggerFactory
import javax.crypto.Cipher
+import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
import java.security.Security
class PasswordBasedEncryptorGroovyTest {
@@ -82,7 +87,7 @@ class PasswordBasedEncryptorGroovyTest {
OutputStream cipherStream = new ByteArrayOutputStream()
OutputStream recoveredStream = new ByteArrayOutputStream()
- logger.info("Using ${kdf.name} and ${encryptionMethod.name()}")
+ logger.info("Using ${kdf.kdfName} and ${encryptionMethod.name()}")
PasswordBasedEncryptor encryptor = new PasswordBasedEncryptor(encryptionMethod, shortPassword.toCharArray(), kdf)
StreamCallback encryptionCallback = encryptor.getEncryptionCallback()
@@ -106,10 +111,63 @@ class PasswordBasedEncryptorGroovyTest {
}
}
+ /**
+ * This test was added after observing an encryption which appended a single {@code 0x10} byte after the cipher text was written. All other bytes in the flowfile content were correct. The corresponding {@code DecryptContent} processor could not decrypt the content and manual decryption required truncating the final byte.
+ * @throws Exception
+ */
+ @Test
+ void testBcryptKDFShouldNotAddOutputBytes() throws Exception {
+ // Arrange
+ final String PLAINTEXT = "This is a plaintext message." * 4
+ logger.info("Plaintext: {}", PLAINTEXT)
+
+ int saltLength = 29
+ int saltDelimiterLength = 8
+ int ivLength = 16
+ int ivDelimiterLength = 6
+ int plaintextBlockCount = (int) Math.ceil(PLAINTEXT.length() / 16.0)
+ int cipherByteLength = (PLAINTEXT.length() % 16 == 0 ? plaintextBlockCount + 1 : plaintextBlockCount) * 16
+ int EXPECTED_CIPHER_BYTE_COUNT = saltLength + saltDelimiterLength + ivLength + ivDelimiterLength + cipherByteLength
+ logger.info("Expected total cipher byte count: ${EXPECTED_CIPHER_BYTE_COUNT}")
+
+ InputStream plainStream = new ByteArrayInputStream(PLAINTEXT.getBytes("UTF-8"))
+
+ String shortPassword = "short"
+
+ EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
+ KeyDerivationFunction kdf = KeyDerivationFunction.BCRYPT
+
+ // Act
+ OutputStream cipherStream = new ByteArrayOutputStream()
+ OutputStream recoveredStream = new ByteArrayOutputStream()
+
+ logger.info("Using ${kdf.kdfName} 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 (${cipherBytes.length}): ${Hex.encodeHexString(cipherBytes)}")
+ assert cipherBytes.length == EXPECTED_CIPHER_BYTE_COUNT
+
+ InputStream cipherInputStream = new ByteArrayInputStream(cipherBytes)
+ decryptionCallback.process(cipherInputStream, recoveredStream)
+
+ // Assert
+ byte[] recoveredBytes = ((ByteArrayOutputStream) recoveredStream).toByteArray()
+ logger.info("Recovered (${recoveredBytes.length}): ${Hex.encodeHexString(recoveredBytes)}")
+ String recovered = new String(recoveredBytes, "UTF-8")
+ logger.info("Recovered: {}\n\n", recovered)
+ assert PLAINTEXT.equals(recovered)
+ }
+
@Test
void testShouldDecryptLegacyOpenSSLSaltedCipherText() throws Exception {
// Arrange
- Assume.assumeTrue("Skipping test because unlimited strength crypto policy not installed", PasswordBasedEncryptor.supportsUnlimitedStrength())
+ Assume.assumeTrue("Skipping test because unlimited strength crypto policy not installed", CipherUtility.isUnlimitedStrengthCryptoSupported())
final String PLAINTEXT = new File("${TEST_RESOURCES_PREFIX}/plain.txt").text
logger.info("Plaintext: {}", PLAINTEXT)
@@ -138,7 +196,7 @@ class PasswordBasedEncryptorGroovyTest {
@Test
void testShouldDecryptLegacyOpenSSLUnsaltedCipherText() throws Exception {
// Arrange
- Assume.assumeTrue("Skipping test because unlimited strength crypto policy not installed", PasswordBasedEncryptor.supportsUnlimitedStrength())
+ Assume.assumeTrue("Skipping test because unlimited strength crypto policy not installed", CipherUtility.isUnlimitedStrengthCryptoSupported())
final String PLAINTEXT = new File("${TEST_RESOURCES_PREFIX}/plain.txt").text
logger.info("Plaintext: {}", PLAINTEXT)
@@ -222,4 +280,230 @@ class PasswordBasedEncryptorGroovyTest {
}
}
}
+
+ @Test
+ void testShouldWriteEncryptionMetadataAttributesForKDFs() throws Exception {
+ // Arrange
+ final String PLAINTEXT = "This is a plaintext message. "
+ logger.info("Plaintext: ${PLAINTEXT}")
+
+ final EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
+ def kdfs = KeyDerivationFunction.values().findAll { it.isStrongKDF() }
+
+ // Act
+ kdfs.each { KeyDerivationFunction kdf ->
+ PasswordBasedEncryptor encryptor = new PasswordBasedEncryptor(encryptionMethod, PASSWORD.toCharArray(), kdf)
+ StreamCallback encryptCallback = encryptor.getEncryptionCallback()
+
+ // Reset the streams
+ InputStream inputStream = new ByteArrayInputStream(PLAINTEXT.bytes)
+ OutputStream cipherStream = new ByteArrayOutputStream()
+
+ encryptCallback.process(inputStream, cipherStream)
+
+ // Assert
+ byte[] cipherBytes = ((ByteArrayOutputStream) cipherStream).toByteArray()
+ String cipherText = new String(cipherBytes, StandardCharsets.UTF_8)
+ String cipherTextHex = Hex.encodeHexString(cipherBytes)
+ logger.info("Cipher text (${cipherBytes.size()}): ${cipherTextHex}")
+
+ int ivDelimiterStart = CipherUtility.findSequence(cipherBytes, RandomIVPBECipherProvider.IV_DELIMITER)
+ logger.info("IV delimiter starts at ${ivDelimiterStart}")
+
+ final byte[] EXPECTED_KDF_SALT_BYTES = TestEncryptContentGroovy.extractFullSaltFromCipherBytes(cipherBytes)
+ final String EXPECTED_KDF_SALT = new String(EXPECTED_KDF_SALT_BYTES)
+ final String EXPECTED_SALT_HEX = TestEncryptContentGroovy.extractRawSaltHexFromFullSalt(EXPECTED_KDF_SALT_BYTES, kdf)
+ logger.info("Extracted expected raw salt (hex): ${EXPECTED_SALT_HEX}")
+
+ final String EXPECTED_IV_HEX = Hex.encodeHexString(cipherBytes[(ivDelimiterStart - 16)..<ivDelimiterStart] as byte[])
+
+ TestEncryptContentGroovy.printFlowFileAttributes(encryptor.flowfileAttributes)
+
+ // Assert the timestamp attribute was written and is accurate
+ def diff = TestEncryptContentGroovy.calculateTimestampDifference(new Date(), encryptor.flowfileAttributes.get("encryptcontent.timestamp"))
+ assert diff.toMilliseconds() < 1_000
+ assert encryptor.flowfileAttributes.get("encryptcontent.algorithm") == encryptionMethod.name()
+ assert encryptor.flowfileAttributes.get("encryptcontent.kdf") == kdf.name()
+ assert encryptor.flowfileAttributes.get("encryptcontent.action") == "encrypted"
+ assert encryptor.flowfileAttributes.get("encryptcontent.salt") == EXPECTED_SALT_HEX
+ assert encryptor.flowfileAttributes.get("encryptcontent.salt_length") == "16"
+ assert encryptor.flowfileAttributes.get("encryptcontent.iv") == EXPECTED_IV_HEX
+ assert encryptor.flowfileAttributes.get("encryptcontent.iv_length") == "16"
+ assert encryptor.flowfileAttributes.get("encryptcontent.plaintext_length") == PLAINTEXT.size() as String
+ assert encryptor.flowfileAttributes.get("encryptcontent.cipher_text_length") == cipherBytes.size() as String
+
+ // PBKDF2 doesn't have a KDF salt, just the raw byte[16]
+ if (kdf != KeyDerivationFunction.PBKDF2) {
+ assert encryptor.flowfileAttributes.get("encryptcontent.kdf_salt") == EXPECTED_KDF_SALT
+ assert (29..54)*.toString().contains(encryptor.flowfileAttributes.get("encryptcontent.kdf_salt_length"))
+ }
+ }
+ }
+
+ @Test
+ void testPBKDF2ShouldWriteIterationsAsAttribute() throws Exception {
+ // Arrange
+ final String PLAINTEXT = "This is a plaintext message. "
+ logger.info("Plaintext: ${PLAINTEXT}")
+
+ final EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
+ KeyDerivationFunction kdf = KeyDerivationFunction.PBKDF2
+ PBKDF2CipherProvider pbkdf2CipherProvider = new PBKDF2CipherProvider()
+ final String EXPECTED_ITERATIONS = pbkdf2CipherProvider.getIterationCount() as String
+
+ // Act
+ PasswordBasedEncryptor encryptor = new PasswordBasedEncryptor(encryptionMethod, PASSWORD.toCharArray(), kdf)
+ StreamCallback encryptCallback = encryptor.getEncryptionCallback()
+
+ // Reset the streams
+ InputStream inputStream = new ByteArrayInputStream(PLAINTEXT.bytes)
+ OutputStream cipherStream = new ByteArrayOutputStream()
+
+ encryptCallback.process(inputStream, cipherStream)
+
+ // Assert
+ byte[] cipherBytes = ((ByteArrayOutputStream) cipherStream).toByteArray()
+ String cipherTextHex = Hex.encodeHexString(cipherBytes)
+ logger.info("Cipher text (${cipherBytes.size()}): ${cipherTextHex}")
+
+ TestEncryptContentGroovy.printFlowFileAttributes(encryptor.flowfileAttributes)
+
+ assert encryptor.flowfileAttributes.get("encryptcontent.algorithm") == encryptionMethod.name()
+ assert encryptor.flowfileAttributes.get("encryptcontent.kdf") == kdf.name()
+ assert encryptor.flowfileAttributes.get("encryptcontent.action") == "encrypted"
+ assert encryptor.flowfileAttributes.get("encryptcontent.pbkdf2_iterations") == EXPECTED_ITERATIONS
+ }
+
+ @Test
+ void testBcryptDecryptShouldSupportLegacyKeyDerivationProcess() throws Exception {
+ // Arrange
+ final String PLAINTEXT = "This is a plaintext message. "
+ logger.info("Plaintext: ${PLAINTEXT}")
+
+ final EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
+ KeyDerivationFunction kdf = KeyDerivationFunction.BCRYPT
+ BcryptCipherProvider bcryptCipherProvider = new BcryptCipherProvider()
+
+ // Replicate PBE encryptor with manual legacy key derivation to encrypt
+ final String PASSWORD = "shortPassword"
+ final byte[] SALT = bcryptCipherProvider.generateSalt()
+ String saltString = new String(SALT, StandardCharsets.UTF_8)
+ logger.test("Using fixed Bcrypt salt: ${saltString}")
+
+ // Determine the expected key bytes using the legacy key derivation process
+ BcryptSecureHasher bcryptSecureHasher = new BcryptSecureHasher(bcryptCipherProvider.getWorkFactor(), bcryptCipherProvider.getDefaultSaltLength())
+ byte[] rawSaltBytes = BcryptCipherProvider.extractRawSalt(saltString)
+ byte[] hashOutputBytes = bcryptSecureHasher.hashRaw(PASSWORD.getBytes(StandardCharsets.UTF_8), rawSaltBytes)
+ logger.test("Raw hash output (${hashOutputBytes.length}): ${Hex.encodeHexString(hashOutputBytes)}")
+
+ MessageDigest sha512 = MessageDigest.getInstance("SHA-512", "BC")
+ byte[] keyDigestBytes = sha512.digest(hashOutputBytes)
+ logger.test("Key digest (${keyDigestBytes.length}): ${Hex.encodeHexString(keyDigestBytes)}")
+
+ int keyLength = CipherUtility.parseKeyLengthFromAlgorithm(encryptionMethod.algorithm)
+ byte[] derivedKeyBytes = Arrays.copyOf(keyDigestBytes, keyLength / 8 as int)
+ logger.test("Derived key (${derivedKeyBytes.length}): ${Hex.encodeHexString(derivedKeyBytes)}")
+
+ StreamCallback customEncryptCallback = { InputStream is, OutputStream os ->
+ byte[] saltBytes = bcryptCipherProvider.generateSalt()
+ ByteCountingInputStream bcis = new ByteCountingInputStream(is)
+ ByteCountingOutputStream bcos = new ByteCountingOutputStream(os)
+ bcryptCipherProvider.writeSalt(saltBytes, bcos)
+
+ Cipher cipher = bcryptCipherProvider.getInitializedCipher(encryptionMethod, PASSWORD, saltBytes, new byte[16], keyLength, true, true)
+
+ bcryptCipherProvider.writeIV(cipher.getIV(), bcos)
+ CipherUtility.processStreams(cipher, bcis, bcos)
+ } as StreamCallback
+
+ // Reset the streams
+ InputStream inputStream = new ByteArrayInputStream(PLAINTEXT.bytes)
+ OutputStream cipherStream = new ByteArrayOutputStream()
+
+ customEncryptCallback.process(inputStream, cipherStream)
+
+ byte[] cipherBytes = ((ByteArrayOutputStream) cipherStream).toByteArray()
+ String cipherTextHex = Hex.encodeHexString(cipherBytes)
+ logger.info("Cipher text (${cipherBytes.size()}): ${cipherTextHex}")
+
+ // Act
+ PasswordBasedEncryptor encryptor = new PasswordBasedEncryptor(encryptionMethod, PASSWORD.toCharArray(), kdf)
+ StreamCallback pbeDecryptCallback = encryptor.getDecryptionCallback()
+
+ // Reset the streams
+ InputStream cipherInputStream = new ByteArrayInputStream(cipherBytes)
+ OutputStream recoveredOutputStream = new ByteArrayOutputStream()
+
+ // Use PBE w/ Bcrypt to decrypt (and handle legacy key derivation process)
+ pbeDecryptCallback.process(cipherInputStream, recoveredOutputStream)
+
+ // Assert
+ byte[] recoveredBytes = ((ByteArrayOutputStream) recoveredOutputStream).toByteArray()
+ String recovered = new String(recoveredBytes, StandardCharsets.UTF_8)
+ logger.info("Plaintext (${recoveredBytes.size()}): ${recovered}")
+
+ assert recovered == PLAINTEXT
+ }
+
+ /**
+ * This test was added to detect a non-deterministic problem with Scrypt expected salts being
+ * 32 bytes. This was ultimately determined to be a problem with the Scrypt salt regex failing
+ * to match salts containing a '+' in the first 12 characters. See
+ * {@code ScryptCipherProviderGroovyTest#testShouldAcceptFormattedSaltWithPlus( )}.
+ *
+ * @throws Exception
+ */
+ @Test
+ void testScryptSaltShouldBe16Bytes() throws Exception {
+ // Arrange
+ final String PLAINTEXT = "This is a plaintext message. "
+ logger.info("Plaintext: ${PLAINTEXT}")
+
+ final EncryptionMethod encryptionMethod = EncryptionMethod.AES_CBC
+ KeyDerivationFunction kdf = KeyDerivationFunction.SCRYPT
+
+ // Act
+ PasswordBasedEncryptor encryptor = new PasswordBasedEncryptor(encryptionMethod, PASSWORD.toCharArray(), kdf)
+ StreamCallback encryptCallback = encryptor.getEncryptionCallback()
+
+ // Reset the streams
+ InputStream inputStream = new ByteArrayInputStream(PLAINTEXT.bytes)
+ OutputStream cipherStream = new ByteArrayOutputStream()
+
+ encryptCallback.process(inputStream, cipherStream)
+
+ // Assert
+ byte[] cipherBytes = ((ByteArrayOutputStream) cipherStream).toByteArray()
+ String cipherText = new String(cipherBytes, StandardCharsets.UTF_8)
+ String cipherTextHex = Hex.encodeHexString(cipherBytes)
+ logger.info("Cipher text (${cipherBytes.size()}): ${cipherTextHex}")
+
+ int ivDelimiterStart = CipherUtility.findSequence(cipherBytes, RandomIVPBECipherProvider.IV_DELIMITER)
+ logger.info("IV delimiter starts at ${ivDelimiterStart}")
+
+ final byte[] EXPECTED_KDF_SALT_BYTES = TestEncryptContentGroovy.extractFullSaltFromCipherBytes(cipherBytes)
+ final String EXPECTED_KDF_SALT = new String(EXPECTED_KDF_SALT_BYTES)
+ final String EXPECTED_SALT_HEX = TestEncryptContentGroovy.extractRawSaltHexFromFullSalt(EXPECTED_KDF_SALT_BYTES, kdf)
+ logger.info("Extracted expected raw salt (hex): ${EXPECTED_SALT_HEX}")
+
+ final String EXPECTED_IV_HEX = Hex.encodeHexString(cipherBytes[(ivDelimiterStart - 16)..<ivDelimiterStart] as byte[])
+
+ TestEncryptContentGroovy.printFlowFileAttributes(encryptor.flowfileAttributes)
+
+ // Assert the timestamp attribute was written and is accurate
+ def diff = TestEncryptContentGroovy.calculateTimestampDifference(new Date(), encryptor.flowfileAttributes.get("encryptcontent.timestamp"))
+ assert diff.toMilliseconds() < 1_000
+ assert encryptor.flowfileAttributes.get("encryptcontent.algorithm") == encryptionMethod.name()
+ assert encryptor.flowfileAttributes.get("encryptcontent.kdf") == kdf.name()
+ assert encryptor.flowfileAttributes.get("encryptcontent.action") == "encrypted"
+ assert encryptor.flowfileAttributes.get("encryptcontent.salt") == EXPECTED_SALT_HEX
+ assert encryptor.flowfileAttributes.get("encryptcontent.salt_length") == "16"
+ assert encryptor.flowfileAttributes.get("encryptcontent.iv") == EXPECTED_IV_HEX
+ assert encryptor.flowfileAttributes.get("encryptcontent.iv_length") == "16"
+ assert encryptor.flowfileAttributes.get("encryptcontent.plaintext_length") == PLAINTEXT.size() as String
+ assert encryptor.flowfileAttributes.get("encryptcontent.cipher_text_length") == cipherBytes.size() as String
+
+ assert encryptor.flowfileAttributes.get("encryptcontent.kdf_salt") == EXPECTED_KDF_SALT
+ assert (29..54)*.toString().contains(encryptor.flowfileAttributes.get("encryptcontent.kdf_salt_length"))
+ }
}
\ No newline at end of file
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 4dd0359..f86d17b 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,18 +16,23 @@
*/
package org.apache.nifi.processors.standard;
+import static org.bouncycastle.openpgp.PGPUtil.getDecoderStream;
+import static org.junit.Assert.fail;
+
import java.io.ByteArrayInputStream;
import java.io.File;
-import java.io.InputStream;
import java.io.IOException;
+import java.io.InputStream;
import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Paths;
import java.security.Security;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.HashSet;
import java.util.List;
import java.util.Objects;
-
+import java.util.Set;
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.lang3.SystemUtils;
import org.apache.nifi.components.AllowableValue;
@@ -51,9 +56,6 @@ import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
-import static org.bouncycastle.openpgp.PGPUtil.getDecoderStream;
-import static org.junit.Assert.fail;
-
public class TestEncryptContent {
private static final Logger logger = LoggerFactory.getLogger(TestEncryptContent.class);
@@ -207,8 +209,8 @@ public class TestEncryptContent {
final String AES_ALGORITHM = EncryptionMethod.MD5_256AES.getAlgorithm();
final String DES_ALGORITHM = EncryptionMethod.MD5_DES.getAlgorithm();
- final int AES_MAX_LENGTH = PasswordBasedEncryptor.supportsUnlimitedStrength() ? Integer.MAX_VALUE : 128;
- final int DES_MAX_LENGTH = PasswordBasedEncryptor.supportsUnlimitedStrength() ? Integer.MAX_VALUE : 64;
+ final int AES_MAX_LENGTH = CipherUtility.isUnlimitedStrengthCryptoSupported() ? Integer.MAX_VALUE : 128;
+ final int DES_MAX_LENGTH = CipherUtility.isUnlimitedStrengthCryptoSupported() ? Integer.MAX_VALUE : 64;
// Act
int determinedAESMaxLength = PasswordBasedEncryptor.getMaxAllowedKeyLength(AES_ALGORITHM);
@@ -223,7 +225,7 @@ public class TestEncryptContent {
public void testShouldDecryptOpenSSLRawSalted() throws IOException {
// Arrange
Assume.assumeTrue("Test is being skipped due to this JVM lacking JCE Unlimited Strength Jurisdiction Policy file.",
- PasswordBasedEncryptor.supportsUnlimitedStrength());
+ CipherUtility.isUnlimitedStrengthCryptoSupported());
final TestRunner testRunner = TestRunners.newTestRunner(new EncryptContent());
@@ -247,7 +249,7 @@ public class TestEncryptContent {
MockFlowFile flowFile = testRunner.getFlowFilesForRelationship(EncryptContent.REL_SUCCESS).get(0);
logger.info("Decrypted contents (hex): {}", Hex.encodeHexString(flowFile.toByteArray()));
- logger.info("Decrypted contents: {}", new String(flowFile.toByteArray(), "UTF-8"));
+ logger.info("Decrypted contents: {}", new String(flowFile.toByteArray(), StandardCharsets.UTF_8));
// Assert
flowFile.assertContentEquals(new File("src/test/resources/TestEncryptContent/plain.txt"));
@@ -257,7 +259,7 @@ public class TestEncryptContent {
public void testShouldDecryptOpenSSLRawUnsalted() throws IOException {
// Arrange
Assume.assumeTrue("Test is being skipped due to this JVM lacking JCE Unlimited Strength Jurisdiction Policy file.",
- PasswordBasedEncryptor.supportsUnlimitedStrength());
+ CipherUtility.isUnlimitedStrengthCryptoSupported());
final TestRunner testRunner = TestRunners.newTestRunner(new EncryptContent());
@@ -281,20 +283,20 @@ public class TestEncryptContent {
MockFlowFile flowFile = testRunner.getFlowFilesForRelationship(EncryptContent.REL_SUCCESS).get(0);
logger.info("Decrypted contents (hex): {}", Hex.encodeHexString(flowFile.toByteArray()));
- logger.info("Decrypted contents: {}", new String(flowFile.toByteArray(), "UTF-8"));
+ logger.info("Decrypted contents: {}", new String(flowFile.toByteArray(), StandardCharsets.UTF_8));
// Assert
flowFile.assertContentEquals(new File("src/test/resources/TestEncryptContent/plain.txt"));
}
@Test
- public void testDecryptShouldDefaultToBcrypt() throws IOException {
+ public void testDecryptShouldDefaultToNone() throws IOException {
// Arrange
final TestRunner testRunner = TestRunners.newTestRunner(new EncryptContent());
// Assert
- Assert.assertEquals("Decrypt should default to Legacy KDF", testRunner.getProcessor().getPropertyDescriptor(EncryptContent.KEY_DERIVATION_FUNCTION
- .getName()).getDefaultValue(), KeyDerivationFunction.BCRYPT.name());
+ Assert.assertEquals("Decrypt should default to None", testRunner.getProcessor().getPropertyDescriptor(EncryptContent.KEY_DERIVATION_FUNCTION
+ .getName()).getDefaultValue(), KeyDerivationFunction.NONE.name());
}
@Test
@@ -303,6 +305,7 @@ public class TestEncryptContent {
runner.setProperty(EncryptContent.PASSWORD, "Hello, World!");
runner.setProperty(EncryptContent.MODE, EncryptContent.DECRYPT_MODE);
runner.setProperty(EncryptContent.KEY_DERIVATION_FUNCTION, KeyDerivationFunction.NIFI_LEGACY.name());
+ runner.setProperty(EncryptContent.ENCRYPTION_ALGORITHM, EncryptionMethod.MD5_128AES.name());
runner.enqueue(new byte[4]);
runner.run();
runner.assertAllFlowFilesTransferred(EncryptContent.REL_FAILURE, 1);
@@ -454,10 +457,24 @@ public class TestEncryptContent {
runner.enqueue(new byte[0]);
pc = (MockProcessContext) runner.getProcessContext();
results = pc.validate();
- Assert.assertEquals(results.toString(), 1, results.size());
+
+ for (ValidationResult vr : results) {
+ logger.info(vr.toString());
+ }
+
+ // The default validation error is:
+ // Raw key hex cannot be empty
+ final String RAW_KEY_ERROR = "'raw-key-hex' is invalid because Raw Key (hexadecimal) is " +
+ "required when using algorithm AES/GCM/NoPadding and KDF KeyDerivationFunction[KDF " +
+ "Name=None,Description=The cipher is given a raw key conforming to the algorithm " +
+ "specifications]. See Admin Guide.";
+
+ final Set<String> EXPECTED_ERRORS = new HashSet<>();
+ EXPECTED_ERRORS.add(RAW_KEY_ERROR);
+
+ Assert.assertEquals(results.toString(), EXPECTED_ERRORS.size(), results.size());
for (final ValidationResult vr : results) {
- Assert.assertTrue(vr.toString()
- .contains(EncryptContent.PASSWORD.getDisplayName() + " is required when using algorithm"));
+ Assert.assertTrue(EXPECTED_ERRORS.contains(vr.toString()));
}
runner.enqueue(new byte[0]);
@@ -467,7 +484,7 @@ public class TestEncryptContent {
runner.setProperty(EncryptContent.PASSWORD, "ThisIsAPasswordThatIsLongerThanSixteenCharacters");
pc = (MockProcessContext) runner.getProcessContext();
results = pc.validate();
- if (!PasswordBasedEncryptor.supportsUnlimitedStrength()) {
+ if (!CipherUtility.isUnlimitedStrengthCryptoSupported()) {
logger.info(results.toString());
Assert.assertEquals(1, results.size());
for (final ValidationResult vr : results) {