| /* |
| * 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.registry.properties; |
| |
| import org.apache.commons.lang3.StringUtils; |
| import org.bouncycastle.jce.provider.BouncyCastleProvider; |
| import org.bouncycastle.util.encoders.Base64; |
| import org.bouncycastle.util.encoders.DecoderException; |
| import org.bouncycastle.util.encoders.EncoderException; |
| import org.bouncycastle.util.encoders.Hex; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import javax.crypto.BadPaddingException; |
| import javax.crypto.Cipher; |
| import javax.crypto.IllegalBlockSizeException; |
| import javax.crypto.NoSuchPaddingException; |
| import javax.crypto.SecretKey; |
| import javax.crypto.spec.IvParameterSpec; |
| import javax.crypto.spec.SecretKeySpec; |
| import java.nio.charset.StandardCharsets; |
| import java.security.InvalidAlgorithmParameterException; |
| import java.security.InvalidKeyException; |
| import java.security.NoSuchAlgorithmException; |
| import java.security.NoSuchProviderException; |
| import java.security.SecureRandom; |
| import java.security.Security; |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.stream.Collectors; |
| |
| public class AESSensitivePropertyProvider implements SensitivePropertyProvider { |
| private static final Logger logger = LoggerFactory.getLogger(AESSensitivePropertyProvider.class); |
| |
| private static final String IMPLEMENTATION_NAME = "AES Sensitive Property Provider"; |
| private static final String IMPLEMENTATION_KEY = "aes/gcm/"; |
| private static final String ALGORITHM = "AES/GCM/NoPadding"; |
| private static final String PROVIDER = "BC"; |
| private static final String DELIMITER = "||"; // "|" is not a valid Base64 character, so ensured not to be present in cipher text |
| private static final int IV_LENGTH = 12; |
| private static final int MIN_CIPHER_TEXT_LENGTH = IV_LENGTH * 4 / 3 + DELIMITER.length() + 1; |
| |
| private Cipher cipher; |
| private final SecretKey key; |
| |
| public AESSensitivePropertyProvider(String keyHex) throws NoSuchPaddingException, NoSuchAlgorithmException, NoSuchProviderException { |
| byte[] key = validateKey(keyHex); |
| |
| try { |
| Security.addProvider(new BouncyCastleProvider()); |
| cipher = Cipher.getInstance(ALGORITHM, PROVIDER); |
| // Only store the key if the cipher was initialized successfully |
| this.key = new SecretKeySpec(key, "AES"); |
| } catch (NoSuchAlgorithmException | NoSuchProviderException | NoSuchPaddingException e) { |
| logger.error("Encountered an error initializing the {}: {}", IMPLEMENTATION_NAME, e.getMessage()); |
| throw new SensitivePropertyProtectionException("Error initializing the protection cipher", e); |
| } |
| } |
| |
| private byte[] validateKey(String keyHex) { |
| if (keyHex == null || StringUtils.isBlank(keyHex)) { |
| throw new SensitivePropertyProtectionException("The key cannot be empty"); |
| } |
| keyHex = formatHexKey(keyHex); |
| if (!isHexKeyValid(keyHex)) { |
| throw new SensitivePropertyProtectionException("The key must be a valid hexadecimal key"); |
| } |
| byte[] key = Hex.decode(keyHex); |
| final List<Integer> validKeyLengths = getValidKeyLengths(); |
| if (!validKeyLengths.contains(key.length * 8)) { |
| List<String> validKeyLengthsAsStrings = validKeyLengths.stream().map(i -> Integer.toString(i)).collect(Collectors.toList()); |
| throw new SensitivePropertyProtectionException("The key (" + key.length * 8 + " bits) must be a valid length: " + StringUtils.join(validKeyLengthsAsStrings, ", ")); |
| } |
| return key; |
| } |
| |
| public AESSensitivePropertyProvider(byte[] key) throws NoSuchPaddingException, NoSuchAlgorithmException, NoSuchProviderException { |
| this(key == null ? "" : Hex.toHexString(key)); |
| } |
| |
| private static String formatHexKey(String input) { |
| if (input == null || StringUtils.isBlank(input)) { |
| return ""; |
| } |
| return input.replaceAll("[^0-9a-fA-F]", "").toLowerCase(); |
| } |
| |
| private static boolean isHexKeyValid(String key) { |
| if (key == null || StringUtils.isBlank(key)) { |
| return false; |
| } |
| // Key length is in "nibbles" (i.e. one hex char = 4 bits) |
| return getValidKeyLengths().contains(key.length() * 4) && key.matches("^[0-9a-fA-F]*$"); |
| } |
| |
| private static List<Integer> getValidKeyLengths() { |
| List<Integer> validLengths = new ArrayList<>(); |
| validLengths.add(128); |
| |
| try { |
| if (Cipher.getMaxAllowedKeyLength("AES") > 128) { |
| validLengths.add(192); |
| validLengths.add(256); |
| } else { |
| logger.warn("JCE Unlimited Strength Cryptography Jurisdiction policies are not available, so the max key length is 128 bits"); |
| } |
| } catch (NoSuchAlgorithmException e) { |
| logger.warn("Encountered an error determining the max key length", e); |
| } |
| |
| return validLengths; |
| } |
| |
| /** |
| * Returns the name of the underlying implementation. |
| * |
| * @return the name of this sensitive property provider |
| */ |
| @Override |
| public String getName() { |
| return IMPLEMENTATION_NAME; |
| } |
| |
| /** |
| * Returns the key used to identify the provider implementation in {@code nifi.properties}. |
| * |
| * @return the key to persist in the sibling property |
| */ |
| @Override |
| public String getIdentifierKey() { |
| return IMPLEMENTATION_KEY + getKeySize(Hex.toHexString(key.getEncoded())); |
| } |
| |
| private int getKeySize(String key) { |
| if (StringUtils.isBlank(key)) { |
| return 0; |
| } else { |
| // A key in hexadecimal format has one char per nibble (4 bits) |
| return formatHexKey(key).length() * 4; |
| } |
| } |
| |
| /** |
| * Returns the encrypted cipher text. |
| * |
| * @param unprotectedValue the sensitive value |
| * @return the value to persist in the {@code nifi.properties} file |
| * @throws SensitivePropertyProtectionException if there is an exception encrypting the value |
| */ |
| @Override |
| public String protect(String unprotectedValue) throws SensitivePropertyProtectionException { |
| if (unprotectedValue == null || unprotectedValue.trim().length() == 0) { |
| throw new IllegalArgumentException("Cannot encrypt an empty value"); |
| } |
| |
| // Generate IV |
| byte[] iv = generateIV(); |
| if (iv.length < IV_LENGTH) { |
| throw new IllegalArgumentException("The IV (" + iv.length + " bytes) must be at least " + IV_LENGTH + " bytes"); |
| } |
| |
| try { |
| // Initialize cipher for encryption |
| cipher.init(Cipher.ENCRYPT_MODE, this.key, new IvParameterSpec(iv)); |
| |
| byte[] plainBytes = unprotectedValue.getBytes(StandardCharsets.UTF_8); |
| byte[] cipherBytes = cipher.doFinal(plainBytes); |
| logger.info(getName() + " encrypted a sensitive value successfully"); |
| return base64Encode(iv) + DELIMITER + base64Encode(cipherBytes); |
| // return Base64.toBase64String(iv) + DELIMITER + Base64.toBase64String(cipherBytes); |
| } catch (BadPaddingException | IllegalBlockSizeException | EncoderException | InvalidAlgorithmParameterException | InvalidKeyException e) { |
| final String msg = "Error encrypting a protected value"; |
| logger.error(msg, e); |
| throw new SensitivePropertyProtectionException(msg, e); |
| } |
| } |
| |
| private String base64Encode(byte[] input) { |
| return Base64.toBase64String(input).replaceAll("=", ""); |
| } |
| |
| /** |
| * Generates a new random IV of 12 bytes using {@link SecureRandom}. |
| * |
| * @return the IV |
| */ |
| private byte[] generateIV() { |
| byte[] iv = new byte[IV_LENGTH]; |
| new SecureRandom().nextBytes(iv); |
| return iv; |
| } |
| |
| /** |
| * Returns the decrypted plaintext. |
| * |
| * @param protectedValue the cipher text read from the {@code nifi.properties} file |
| * @return the raw value to be used by the application |
| * @throws SensitivePropertyProtectionException if there is an error decrypting the cipher text |
| */ |
| @Override |
| public String unprotect(String protectedValue) throws SensitivePropertyProtectionException { |
| if (protectedValue == null || protectedValue.trim().length() < MIN_CIPHER_TEXT_LENGTH) { |
| throw new IllegalArgumentException("Cannot decrypt a cipher text shorter than " + MIN_CIPHER_TEXT_LENGTH + " chars"); |
| } |
| |
| if (!protectedValue.contains(DELIMITER)) { |
| throw new IllegalArgumentException("The cipher text does not contain the delimiter " + DELIMITER + " -- it should be of the form Base64(IV) || Base64(cipherText)"); |
| } |
| |
| protectedValue = protectedValue.trim(); |
| |
| final String IV_B64 = protectedValue.substring(0, protectedValue.indexOf(DELIMITER)); |
| byte[] iv = Base64.decode(IV_B64); |
| if (iv.length < IV_LENGTH) { |
| throw new IllegalArgumentException("The IV (" + iv.length + " bytes) must be at least " + IV_LENGTH + " bytes"); |
| } |
| |
| String CIPHERTEXT_B64 = protectedValue.substring(protectedValue.indexOf(DELIMITER) + 2); |
| |
| // Restore the = padding if necessary to reconstitute the GCM MAC check |
| if (CIPHERTEXT_B64.length() % 4 != 0) { |
| final int paddedLength = CIPHERTEXT_B64.length() + 4 - (CIPHERTEXT_B64.length() % 4); |
| CIPHERTEXT_B64 = StringUtils.rightPad(CIPHERTEXT_B64, paddedLength, '='); |
| } |
| |
| try { |
| byte[] cipherBytes = Base64.decode(CIPHERTEXT_B64); |
| |
| cipher.init(Cipher.DECRYPT_MODE, this.key, new IvParameterSpec(iv)); |
| byte[] plainBytes = cipher.doFinal(cipherBytes); |
| logger.debug(getName() + " decrypted a sensitive value successfully"); |
| return new String(plainBytes, StandardCharsets.UTF_8); |
| } catch (BadPaddingException | IllegalBlockSizeException | DecoderException | InvalidAlgorithmParameterException | InvalidKeyException e) { |
| final String msg = "Error decrypting a protected value"; |
| logger.error(msg, e); |
| throw new SensitivePropertyProtectionException(msg, e); |
| } |
| } |
| |
| public static int getIvLength() { |
| return IV_LENGTH; |
| } |
| |
| public static int getMinCipherTextLength() { |
| return MIN_CIPHER_TEXT_LENGTH; |
| } |
| |
| public static String getDelimiter() { |
| return DELIMITER; |
| } |
| } |