This is an automated email from the ASF dual-hosted git repository.
exceptionfactory pushed a commit to branch support/nifi-1.x
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/support/nifi-1.x by this push:
new 68dc0653c3 NIFI-12033 Added EncryptContentAge and DecryptContentAge
Processors
68dc0653c3 is described below
commit 68dc0653c33a28d73de2859a4ce8af5743059e71
Author: exceptionfactory <[email protected]>
AuthorDate: Sat Sep 9 09:38:54 2023 -0500
NIFI-12033 Added EncryptContentAge and DecryptContentAge Processors
This closes #7676
Signed-off-by: Paul Grey <[email protected]>
(cherry picked from commit ebe8b9a2e78d1d87046eb1d6c9f86e86203b6744)
---
.../nifi-cipher-processors/pom.xml | 12 +
.../nifi/processors/cipher/DecryptContentAge.java | 299 +++++++++++++++++++++
.../nifi/processors/cipher/EncryptContentAge.java | 299 +++++++++++++++++++++
.../cipher/age/AbstractAgeKeyReader.java | 78 ++++++
.../processors/cipher/age/AgeKeyIndicator.java | 47 ++++
.../nifi/processors/cipher/age/AgeKeyReader.java | 37 +++
.../processors/cipher/age/AgeKeyValidator.java | 146 ++++++++++
.../processors/cipher/age/AgePrivateKeyReader.java | 72 +++++
.../processors/cipher/age/AgeProviderResolver.java | 72 +++++
.../processors/cipher/age/AgePublicKeyReader.java | 72 +++++
.../nifi/processors/cipher/age/FileEncoding.java | 49 ++++
.../nifi/processors/cipher/age/KeySource.java | 49 ++++
.../cipher/io/ChannelStreamCallback.java | 98 +++++++
.../services/org.apache.nifi.processor.Processor | 2 +
.../processors/cipher/DecryptContentAgeTest.java | 232 ++++++++++++++++
.../processors/cipher/EncryptContentAgeTest.java | 246 +++++++++++++++++
.../cipher/age/AgePrivateKeyReaderTest.java | 66 +++++
.../cipher/age/AgePublicKeyReaderTest.java | 66 +++++
.../cipher/io/ChannelStreamCallbackTest.java | 123 +++++++++
nifi-nar-bundles/nifi-cipher-bundle/pom.xml | 12 +
20 files changed, 2077 insertions(+)
diff --git a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/pom.xml
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/pom.xml
index 0948704dcc..4b9149a012 100644
--- a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/pom.xml
+++ b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/pom.xml
@@ -43,6 +43,18 @@
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
</dependency>
+ <dependency>
+ <groupId>com.exceptionfactory.jagged</groupId>
+ <artifactId>jagged-x25519</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.exceptionfactory.jagged</groupId>
+ <artifactId>jagged-framework</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.exceptionfactory.jagged</groupId>
+ <artifactId>jagged-api</artifactId>
+ </dependency>
<dependency>
<groupId>org.apache.nifi</groupId>
<artifactId>nifi-mock</artifactId>
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/DecryptContentAge.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/DecryptContentAge.java
new file mode 100644
index 0000000000..833e4a47c8
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/DecryptContentAge.java
@@ -0,0 +1,299 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher;
+
+import com.exceptionfactory.jagged.DecryptingChannelFactory;
+import com.exceptionfactory.jagged.RecipientStanzaReader;
+import
com.exceptionfactory.jagged.framework.armor.ArmoredDecryptingChannelFactory;
+import
com.exceptionfactory.jagged.framework.stream.StandardDecryptingChannelFactory;
+
+import org.apache.nifi.annotation.behavior.InputRequirement;
+import org.apache.nifi.annotation.behavior.SupportsBatching;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.SeeAlso;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnScheduled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.resource.ResourceCardinality;
+import org.apache.nifi.components.resource.ResourceReference;
+import org.apache.nifi.components.resource.ResourceReferences;
+import org.apache.nifi.components.resource.ResourceType;
+import org.apache.nifi.context.PropertyContext;
+import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.AbstractProcessor;
+import org.apache.nifi.processor.ProcessContext;
+import org.apache.nifi.processor.ProcessSession;
+import org.apache.nifi.processor.Relationship;
+import org.apache.nifi.processor.VerifiableProcessor;
+import org.apache.nifi.processor.io.StreamCallback;
+import org.apache.nifi.processors.cipher.age.AgeKeyIndicator;
+import org.apache.nifi.processors.cipher.age.AgeKeyReader;
+import org.apache.nifi.processors.cipher.age.AgeKeyValidator;
+import org.apache.nifi.processors.cipher.age.AgePrivateKeyReader;
+import org.apache.nifi.processors.cipher.age.AgeProviderResolver;
+import org.apache.nifi.processors.cipher.age.KeySource;
+import org.apache.nifi.processors.cipher.io.ChannelStreamCallback;
+import org.apache.nifi.stream.io.StreamUtils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.PushbackInputStream;
+import java.nio.channels.ReadableByteChannel;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.Provider;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@SupportsBatching
+@InputRequirement(InputRequirement.Requirement.INPUT_REQUIRED)
+@Tags({"age", "age-encryption.org", "encryption", "ChaCha20-Poly1305",
"X25519"})
+@CapabilityDescription(
+ "Decrypt content using the age-encryption.org/v1 specification. " +
+ "Detects binary or ASCII armored content encoding using the initial
file header bytes. " +
+ "The age standard uses ChaCha20-Poly1305 for authenticated encryption
of the payload. " +
+ "The age-keygen command supports generating X25519 key pairs for
encryption and decryption operations."
+)
+@SeeAlso({ EncryptContentAge.class })
+public class DecryptContentAge extends AbstractProcessor implements
VerifiableProcessor {
+
+ static final Relationship SUCCESS = new Relationship.Builder()
+ .name("success")
+ .description("Decryption Completed")
+ .build();
+
+ static final Relationship FAILURE = new Relationship.Builder()
+ .name("failure")
+ .description("Decryption Failed")
+ .build();
+
+ static final PropertyDescriptor PRIVATE_KEY_SOURCE = new
PropertyDescriptor.Builder()
+ .name("Private Key Source")
+ .displayName("Private Key Source")
+ .description("Source of information determines the loading
strategy for X25519 Private Key Identities")
+ .required(true)
+ .defaultValue(KeySource.PROPERTIES.getValue())
+ .allowableValues(KeySource.class)
+ .build();
+
+ static final PropertyDescriptor PRIVATE_KEY_IDENTITIES = new
PropertyDescriptor.Builder()
+ .name("Private Key Identities")
+ .displayName("Private Key Identities")
+ .description("One or more X25519 Private Key Identities, separated
with newlines, encoded according to the age specification, starting with
AGE-SECRET-KEY-1")
+ .required(true)
+ .sensitive(true)
+ .addValidator(new AgeKeyValidator(AgeKeyIndicator.PRIVATE_KEY))
+ .identifiesExternalResource(ResourceCardinality.SINGLE,
ResourceType.TEXT)
+ .dependsOn(PRIVATE_KEY_SOURCE, KeySource.PROPERTIES)
+ .build();
+
+ static final PropertyDescriptor PRIVATE_KEY_IDENTITY_RESOURCES = new
PropertyDescriptor.Builder()
+ .name("Private Key Identity Resources")
+ .displayName("Private Key Identity Resources")
+ .description("One or more files or URLs containing X25519 Private
Key Identities, separated with newlines, encoded according to the age
specification, starting with AGE-SECRET-KEY-1")
+ .required(true)
+ .addValidator(new AgeKeyValidator(AgeKeyIndicator.PRIVATE_KEY))
+ .identifiesExternalResource(ResourceCardinality.MULTIPLE,
ResourceType.FILE, ResourceType.URL)
+ .dependsOn(PRIVATE_KEY_SOURCE, KeySource.RESOURCES)
+ .build();
+
+ private static final Set<Relationship> RELATIONSHIPS = new
LinkedHashSet<>(Arrays.asList(SUCCESS, FAILURE));
+
+ private static final List<PropertyDescriptor> DESCRIPTORS = Arrays.asList(
+ PRIVATE_KEY_SOURCE,
+ PRIVATE_KEY_IDENTITIES,
+ PRIVATE_KEY_IDENTITY_RESOURCES
+ );
+
+ private static final AgeKeyReader<RecipientStanzaReader>
PRIVATE_KEY_READER = new AgePrivateKeyReader();
+
+ private static final Provider CIPHER_PROVIDER =
AgeProviderResolver.getCipherProvider();
+
+ /** 64 Kilobyte buffer plus 16 bytes for authentication tag aligns with
age-encryption payload chunk sizing */
+ private static final int BUFFER_CAPACITY = 65552;
+
+ /** age-encryption.org version indicator at the beginning of binary
encoded files */
+ private static final String VERSION_INDICATOR = "age-encryption.org";
+
+ private static final byte[] BINARY_VERSION_INDICATOR =
VERSION_INDICATOR.getBytes(StandardCharsets.US_ASCII);
+
+ private static final int INPUT_BUFFER_SIZE =
BINARY_VERSION_INDICATOR.length;
+
+ private static final String KEY_VERIFICATION_STEP = "Verify Private Key
Identities";
+
+ private static final String NOT_FOUND_EXPLANATION = "Private Key
Identities not found";
+
+ private volatile List<RecipientStanzaReader>
configuredRecipientStanzaReaders = Collections.emptyList();
+
+ /**
+ * Get Relationships
+ *
+ * @return Processor Relationships
+ */
+ @Override
+ public Set<Relationship> getRelationships() {
+ return RELATIONSHIPS;
+ }
+
+ /**
+ * Get Supported Property Descriptors
+ *
+ * @return Processor Supported Property Descriptors
+ */
+ @Override
+ public final List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+ return DESCRIPTORS;
+ }
+
+ /**
+ * Verify Private Key Identities
+ *
+ * @param context Process Context with configured properties
+ * @param verificationLogger Logger for writing verification results
+ * @param attributes Sample FlowFile attributes for property value
resolution
+ * @return Configuration Verification Results
+ */
+ @Override
+ public List<ConfigVerificationResult> verify(final ProcessContext context,
final ComponentLog verificationLogger, final Map<String, String> attributes) {
+ final List<ConfigVerificationResult> results = new ArrayList<>();
+
+ final ConfigVerificationResult.Builder verificationBuilder = new
ConfigVerificationResult.Builder()
+ .verificationStepName(KEY_VERIFICATION_STEP);
+
+ try {
+ final List<RecipientStanzaReader> recipientStanzaReaders =
getRecipientStanzaReaders(context);
+
+ if (recipientStanzaReaders.isEmpty()) {
+ verificationLogger.warn(NOT_FOUND_EXPLANATION);
+
verificationBuilder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(NOT_FOUND_EXPLANATION);
+ } else {
+ final String explanation = String.format("Private Key
Identities found: %d", recipientStanzaReaders.size());
+ verificationLogger.info(explanation);
+
verificationBuilder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL).explanation(explanation);
+ }
+ } catch (final Exception e) {
+ final String explanation = String.format("%s: %s",
NOT_FOUND_EXPLANATION, e);
+ verificationLogger.warn(NOT_FOUND_EXPLANATION, e);
+
+
verificationBuilder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+ }
+
+ results.add(verificationBuilder.build());
+ return results;
+ }
+
+ @OnScheduled
+ public void onScheduled(final ProcessContext context) throws IOException {
+ configuredRecipientStanzaReaders = getRecipientStanzaReaders(context);
+ }
+
+ @Override
+ public void onTrigger(final ProcessContext context, final ProcessSession
session) {
+ FlowFile flowFile = session.get();
+ if (flowFile == null) {
+ return;
+ }
+
+ try {
+ final StreamCallback streamCallback = new
DecryptingStreamCallback(configuredRecipientStanzaReaders);
+ flowFile = session.write(flowFile, streamCallback);
+
+ session.transfer(flowFile, SUCCESS);
+ } catch (final Exception e) {
+ getLogger().error("Decryption Failed {}", flowFile, e);
+ session.transfer(flowFile, FAILURE);
+ }
+ }
+
+ private List<RecipientStanzaReader> getRecipientStanzaReaders(final
PropertyContext context) throws IOException {
+ final KeySource keySource =
KeySource.valueOf(context.getProperty(PRIVATE_KEY_SOURCE).getValue());
+
+ final List<ResourceReference> resources = new ArrayList<>();
+
+ if (KeySource.PROPERTIES == keySource) {
+ final ResourceReference resource =
context.getProperty(PRIVATE_KEY_IDENTITIES).asResource();
+ resources.add(resource);
+ } else {
+ final ResourceReferences resourceReferences =
context.getProperty(PRIVATE_KEY_IDENTITY_RESOURCES).asResources();
+ resources.addAll(resourceReferences.asList());
+ }
+
+ final List<RecipientStanzaReader> recipientStanzaReaders = new
ArrayList<>();
+
+ for (final ResourceReference resource : resources) {
+ try (final InputStream inputStream = resource.read()) {
+ final List<RecipientStanzaReader> readers =
PRIVATE_KEY_READER.read(inputStream);
+ recipientStanzaReaders.addAll(readers);
+ }
+ }
+
+ if (recipientStanzaReaders.isEmpty()) {
+ throw new IOException(NOT_FOUND_EXPLANATION);
+ }
+
+ return recipientStanzaReaders;
+ }
+
+ private static class DecryptingStreamCallback extends
ChannelStreamCallback {
+ private final List<RecipientStanzaReader> recipientStanzaReaders;
+
+ private DecryptingStreamCallback(final List<RecipientStanzaReader>
recipientStanzaReaders) {
+ super(BUFFER_CAPACITY);
+ this.recipientStanzaReaders = recipientStanzaReaders;
+ }
+
+ @Override
+ protected ReadableByteChannel getReadableChannel(final InputStream
inputStream) throws IOException {
+ final PushbackInputStream pushbackInputStream = new
PushbackInputStream(inputStream, INPUT_BUFFER_SIZE);
+ final byte[] versionIndicator =
getVersionIndicator(pushbackInputStream);
+
+ final DecryptingChannelFactory decryptingChannelFactory =
getDecryptingChannelFactory(versionIndicator);
+ try {
+ final ReadableByteChannel inputChannel =
super.getReadableChannel(pushbackInputStream);
+ return
decryptingChannelFactory.newDecryptingChannel(inputChannel,
recipientStanzaReaders);
+ } catch (final GeneralSecurityException e) {
+ throw new IOException("Channel initialization failed", e);
+ }
+ }
+
+ private byte[] getVersionIndicator(final PushbackInputStream
pushbackInputStream) throws IOException {
+ final byte[] versionIndicator = new byte[INPUT_BUFFER_SIZE];
+ StreamUtils.fillBuffer(pushbackInputStream, versionIndicator);
+ pushbackInputStream.unread(versionIndicator);
+ return versionIndicator;
+ }
+
+ private DecryptingChannelFactory getDecryptingChannelFactory(final
byte[] versionIndicator) {
+ final DecryptingChannelFactory decryptingChannelFactory;
+
+ if (Arrays.equals(BINARY_VERSION_INDICATOR, versionIndicator)) {
+ decryptingChannelFactory = new
StandardDecryptingChannelFactory(CIPHER_PROVIDER);
+ } else {
+ decryptingChannelFactory = new
ArmoredDecryptingChannelFactory(CIPHER_PROVIDER);
+ }
+
+ return decryptingChannelFactory;
+ }
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/EncryptContentAge.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/EncryptContentAge.java
new file mode 100644
index 0000000000..0d37127e50
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/EncryptContentAge.java
@@ -0,0 +1,299 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher;
+
+import com.exceptionfactory.jagged.EncryptingChannelFactory;
+import com.exceptionfactory.jagged.RecipientStanzaWriter;
+import
com.exceptionfactory.jagged.framework.armor.ArmoredEncryptingChannelFactory;
+import
com.exceptionfactory.jagged.framework.stream.StandardEncryptingChannelFactory;
+
+import org.apache.nifi.annotation.behavior.InputRequirement;
+import org.apache.nifi.annotation.behavior.SupportsBatching;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.SeeAlso;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnScheduled;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.resource.ResourceCardinality;
+import org.apache.nifi.components.resource.ResourceReference;
+import org.apache.nifi.components.resource.ResourceReferences;
+import org.apache.nifi.components.resource.ResourceType;
+import org.apache.nifi.context.PropertyContext;
+import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.logging.ComponentLog;
+import org.apache.nifi.processor.AbstractProcessor;
+import org.apache.nifi.processor.ProcessContext;
+import org.apache.nifi.processor.ProcessSession;
+import org.apache.nifi.processor.Relationship;
+import org.apache.nifi.processor.VerifiableProcessor;
+import org.apache.nifi.processor.io.StreamCallback;
+import org.apache.nifi.processors.cipher.age.AgeKeyIndicator;
+import org.apache.nifi.processors.cipher.age.AgeKeyReader;
+import org.apache.nifi.processors.cipher.age.AgeKeyValidator;
+import org.apache.nifi.processors.cipher.age.AgePublicKeyReader;
+import org.apache.nifi.processors.cipher.age.KeySource;
+import org.apache.nifi.processors.cipher.io.ChannelStreamCallback;
+import org.apache.nifi.processors.cipher.age.FileEncoding;
+import org.apache.nifi.processors.cipher.age.AgeProviderResolver;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.channels.WritableByteChannel;
+import java.security.GeneralSecurityException;
+import java.security.Provider;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@SupportsBatching
+@InputRequirement(InputRequirement.Requirement.INPUT_REQUIRED)
+@Tags({"age", "age-encryption.org", "encryption", "ChaCha20-Poly1305",
"X25519"})
+@CapabilityDescription(
+ "Encrypt content using the age-encryption.org/v1 specification. " +
+ "Supports binary or ASCII armored content encoding using configurable
properties. " +
+ "The age standard uses ChaCha20-Poly1305 for authenticated encryption
of the payload. " +
+ "The age-keygen command supports generating X25519 key pairs for
encryption and decryption operations."
+)
+@SeeAlso({ DecryptContentAge.class })
+public class EncryptContentAge extends AbstractProcessor implements
VerifiableProcessor {
+
+ static final Relationship SUCCESS = new Relationship.Builder()
+ .name("success")
+ .description("Encryption Completed")
+ .build();
+
+ static final Relationship FAILURE = new Relationship.Builder()
+ .name("failure")
+ .description("Encryption Failed")
+ .build();
+
+ static final PropertyDescriptor FILE_ENCODING = new
PropertyDescriptor.Builder()
+ .name("File Encoding")
+ .displayName("File Encoding")
+ .description("Output encoding for encrypted files. Binary encoding
provides optimal processing performance.")
+ .required(true)
+ .defaultValue(FileEncoding.BINARY.getValue())
+ .allowableValues(FileEncoding.class)
+ .build();
+
+ static final PropertyDescriptor PUBLIC_KEY_SOURCE = new
PropertyDescriptor.Builder()
+ .name("Public Key Source")
+ .displayName("Public Key Source")
+ .description("Source of information determines the loading
strategy for X25519 Public Key Recipients")
+ .required(true)
+ .defaultValue(KeySource.PROPERTIES.getValue())
+ .allowableValues(KeySource.class)
+ .build();
+
+ static final PropertyDescriptor PUBLIC_KEY_RECIPIENTS = new
PropertyDescriptor.Builder()
+ .name("Public Key Recipients")
+ .displayName("Public Key Recipients")
+ .description("One or more X25519 Public Key Recipients, separated
with newlines, encoded according to the age specification, starting with age1")
+ .required(true)
+ .sensitive(true)
+ .addValidator(new AgeKeyValidator(AgeKeyIndicator.PUBLIC_KEY))
+ .identifiesExternalResource(ResourceCardinality.SINGLE,
ResourceType.TEXT)
+ .dependsOn(PUBLIC_KEY_SOURCE, KeySource.PROPERTIES)
+ .build();
+
+ static final PropertyDescriptor PUBLIC_KEY_RECIPIENT_RESOURCES = new
PropertyDescriptor.Builder()
+ .name("Public Key Recipient Resources")
+ .displayName("Public Key Recipient Resources")
+ .description("One or more files or URLs containing X25519 Public
Key Recipients, separated with newlines, encoded according to the age
specification, starting with age1")
+ .required(true)
+ .addValidator(new AgeKeyValidator(AgeKeyIndicator.PUBLIC_KEY))
+ .identifiesExternalResource(ResourceCardinality.MULTIPLE,
ResourceType.FILE, ResourceType.URL)
+ .dependsOn(PUBLIC_KEY_SOURCE, KeySource.RESOURCES)
+ .build();
+
+ private static final Set<Relationship> RELATIONSHIPS = new
LinkedHashSet<>(Arrays.asList(SUCCESS, FAILURE));
+
+ private static final List<PropertyDescriptor> DESCRIPTORS = Arrays.asList(
+ FILE_ENCODING,
+ PUBLIC_KEY_SOURCE,
+ PUBLIC_KEY_RECIPIENTS,
+ PUBLIC_KEY_RECIPIENT_RESOURCES
+ );
+
+ private static final Provider CIPHER_PROVIDER =
AgeProviderResolver.getCipherProvider();
+
+ private static final AgeKeyReader<RecipientStanzaWriter> PUBLIC_KEY_READER
= new AgePublicKeyReader();
+
+ /** 64 Kilobyte buffer aligns with age-encryption payload chunk sizing */
+ private static final int BUFFER_CAPACITY = 65535;
+
+ private static final String KEY_VERIFICATION_STEP = "Verify Public Key
Recipients";
+
+ private static final String NOT_FOUND_EXPLANATION = "Public Key Recipients
not found";
+
+ private volatile List<RecipientStanzaWriter>
configuredRecipientStanzaWriters = Collections.emptyList();
+
+ /**
+ * Get Relationships
+ *
+ * @return Processor Relationships
+ */
+ @Override
+ public Set<Relationship> getRelationships() {
+ return RELATIONSHIPS;
+ }
+
+ /**
+ * Get Supported Property Descriptors
+ *
+ * @return Processor Supported Property Descriptors
+ */
+ @Override
+ public final List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+ return DESCRIPTORS;
+ }
+
+ /**
+ * Verify Public Key Identities
+ *
+ * @param context Process Context with configured properties
+ * @param verificationLogger Logger for writing verification results
+ * @param attributes Sample FlowFile attributes for property value
resolution
+ * @return Configuration Verification Results
+ */
+ @Override
+ public List<ConfigVerificationResult> verify(final ProcessContext context,
final ComponentLog verificationLogger, final Map<String, String> attributes) {
+ final List<ConfigVerificationResult> results = new ArrayList<>();
+
+ final ConfigVerificationResult.Builder verificationBuilder = new
ConfigVerificationResult.Builder()
+ .verificationStepName(KEY_VERIFICATION_STEP);
+
+ try {
+ final List<RecipientStanzaWriter> recipientStanzaWriters =
getRecipientStanzaWriters(context);
+
+ if (recipientStanzaWriters.isEmpty()) {
+ verificationLogger.warn(NOT_FOUND_EXPLANATION);
+
verificationBuilder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(NOT_FOUND_EXPLANATION);
+ } else {
+ final String explanation = String.format("Public Key
Recipients found: %d", recipientStanzaWriters.size());
+ verificationLogger.info(explanation);
+
verificationBuilder.outcome(ConfigVerificationResult.Outcome.SUCCESSFUL).explanation(explanation);
+ }
+ } catch (final Exception e) {
+ final String explanation = String.format("Public Key Recipients
not found: %s", e.getMessage());
+ verificationLogger.warn("Public Key Recipients not found", e);
+
+
verificationBuilder.outcome(ConfigVerificationResult.Outcome.FAILED).explanation(explanation);
+ }
+
+ results.add(verificationBuilder.build());
+ return results;
+ }
+
+ @OnScheduled
+ public void onScheduled(final ProcessContext context) throws IOException {
+ configuredRecipientStanzaWriters = getRecipientStanzaWriters(context);
+ }
+
+ @Override
+ public void onTrigger(final ProcessContext context, final ProcessSession
session) {
+ FlowFile flowFile = session.get();
+ if (flowFile == null) {
+ return;
+ }
+
+ try {
+ final String fileEncodingValue =
context.getProperty(FILE_ENCODING).getValue();
+ final FileEncoding fileEncoding =
FileEncoding.valueOf(fileEncodingValue);
+ final EncryptingChannelFactory encryptingChannelFactory =
getEncryptingChannelFactory(fileEncoding);
+ final StreamCallback streamCallback = new
EncryptingStreamCallback(configuredRecipientStanzaWriters,
encryptingChannelFactory);
+ flowFile = session.write(flowFile, streamCallback);
+
+ session.transfer(flowFile, SUCCESS);
+ } catch (final Exception e) {
+ getLogger().error("Encryption Failed {}", flowFile, e);
+ session.transfer(flowFile, FAILURE);
+ }
+ }
+
+ private EncryptingChannelFactory getEncryptingChannelFactory(final
FileEncoding fileEncoding) {
+ final EncryptingChannelFactory encryptingChannelFactory;
+
+ if (FileEncoding.ASCII == fileEncoding) {
+ encryptingChannelFactory = new
ArmoredEncryptingChannelFactory(CIPHER_PROVIDER);
+ } else {
+ encryptingChannelFactory = new
StandardEncryptingChannelFactory(CIPHER_PROVIDER);
+ }
+
+ return encryptingChannelFactory;
+ }
+
+ private List<RecipientStanzaWriter> getRecipientStanzaWriters(final
PropertyContext context) throws IOException {
+ final KeySource keySource =
KeySource.valueOf(context.getProperty(PUBLIC_KEY_SOURCE).getValue());
+
+ final List<ResourceReference> resources = new ArrayList<>();
+
+ if (KeySource.PROPERTIES == keySource) {
+ final ResourceReference resource =
context.getProperty(PUBLIC_KEY_RECIPIENTS).asResource();
+ resources.add(resource);
+ } else {
+ final ResourceReferences resourceReferences =
context.getProperty(PUBLIC_KEY_RECIPIENT_RESOURCES).asResources();
+ resources.addAll(resourceReferences.asList());
+ }
+
+ final List<RecipientStanzaWriter> recipientStanzaWriters = new
ArrayList<>();
+
+ for (final ResourceReference resource : resources) {
+ try (final InputStream inputStream = resource.read()) {
+ final List<RecipientStanzaWriter> writers =
PUBLIC_KEY_READER.read(inputStream);
+ recipientStanzaWriters.addAll(writers);
+ }
+ }
+
+ if (recipientStanzaWriters.isEmpty()) {
+ throw new IOException(NOT_FOUND_EXPLANATION);
+ }
+
+ return recipientStanzaWriters;
+ }
+
+ private static class EncryptingStreamCallback extends
ChannelStreamCallback {
+ private final List<RecipientStanzaWriter> recipientStanzaWriters;
+
+ private final EncryptingChannelFactory encryptingChannelFactory;
+
+ private EncryptingStreamCallback(
+ final List<RecipientStanzaWriter> recipientStanzaWriters,
+ final EncryptingChannelFactory encryptingChannelFactory
+ ) {
+ super(BUFFER_CAPACITY);
+ this.recipientStanzaWriters = recipientStanzaWriters;
+ this.encryptingChannelFactory = encryptingChannelFactory;
+ }
+
+ @Override
+ protected WritableByteChannel getWritableChannel(final OutputStream
outputStream) throws IOException {
+ try {
+ final WritableByteChannel outputChannel =
super.getWritableChannel(outputStream);
+ return
encryptingChannelFactory.newEncryptingChannel(outputChannel,
recipientStanzaWriters);
+ } catch (final GeneralSecurityException e) {
+ throw new IOException("Channel initialization failed", e);
+ }
+ }
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AbstractAgeKeyReader.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AbstractAgeKeyReader.java
new file mode 100644
index 0000000000..ec5c0ec23b
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AbstractAgeKeyReader.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher.age;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.stream.Collectors;
+
+/**
+ * Shared key reader abstraction for age-encryption.org public and private
keys using configurable prefix and pattern
+ *
+ * @param <T> Type of parsed key
+ */
+abstract class AbstractAgeKeyReader<T> implements AgeKeyReader<T> {
+
+ private final AgeKeyIndicator ageKeyIndicator;
+
+ /**
+ * Key Reader with prefix for initial line filtering and pattern for
subsequent matching
+ *
+ * @param ageKeyIndicator Key Indicator
+ */
+ AbstractAgeKeyReader(final AgeKeyIndicator ageKeyIndicator) {
+ this.ageKeyIndicator = ageKeyIndicator;
+ }
+
+ /**
+ * Read keys from Input Stream based on lines starting with indicated
prefix and matched against configured pattern
+ *
+ * @param inputStream Input Stream to be parsed
+ * @return Parsed key objects
+ * @throws IOException Thrown on failures reading Input Stream
+ */
+ @Override
+ public List<T> read(final InputStream inputStream) throws IOException {
+ Objects.requireNonNull(inputStream, "Input Stream required");
+
+ try (final BufferedReader reader = new BufferedReader(new
InputStreamReader(inputStream))) {
+ final Set<String> keys = reader.lines()
+ .filter(line ->
line.startsWith(ageKeyIndicator.getPrefix()))
+ .map(ageKeyIndicator.getPattern()::matcher)
+ .filter(Matcher::matches)
+ .map(Matcher::group)
+ .collect(Collectors.toSet());
+
+ return readKeys(keys);
+ }
+ }
+
+ /**
+ * Read encoded keys filtered from Input Stream based on matched pattern
+ *
+ * @param keys Encoded keys
+ * @return Parsed key objects
+ * @throws IOException Thrown on failures reading encoded keys
+ */
+ protected abstract List<T> readKeys(Set<String> keys) throws IOException;
+}
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgeKeyIndicator.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgeKeyIndicator.java
new file mode 100644
index 0000000000..0ab6b3baca
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgeKeyIndicator.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher.age;
+
+import java.util.regex.Pattern;
+
+/**
+ * Pattern indicators for age-encryption.org public and private keys
+ */
+public enum AgeKeyIndicator {
+ /** Human-Readable Part with Separator and enumeration of allowed Bech32
uppercase characters from BIP 0173 */
+ PRIVATE_KEY("AGE-SECRET-KEY-1",
Pattern.compile("^AGE-SECRET-KEY-1[QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L]{58}$")),
+
+ /** Human-Readable Part with Separator and enumeration of allowed Bech32
lowercase characters from BIP 0173 */
+ PUBLIC_KEY("age1",
Pattern.compile("^age1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58}$"));
+
+ private final String prefix;
+
+ private final Pattern pattern;
+
+ AgeKeyIndicator(final String prefix, final Pattern pattern) {
+ this.prefix = prefix;
+ this.pattern = pattern;
+ }
+
+ public String getPrefix() {
+ return prefix;
+ }
+
+ public Pattern getPattern() {
+ return pattern;
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgeKeyReader.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgeKeyReader.java
new file mode 100644
index 0000000000..b4e7070588
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgeKeyReader.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher.age;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+
+/**
+ * Abstraction for reading public or private keys encoded using Bech32
supporting age-encryption.org
+ *
+ * @param <T> Key Type
+ */
+public interface AgeKeyReader<T> {
+ /**
+ * Read Input Stream and return collection of parsed objects corresponding
to parsed keys
+ *
+ * @param inputStream Input Stream to be parsed
+ * @return Collection of parsed objects
+ * @throws IOException Thrown on failure to read Input Stream
+ */
+ List<T> read(InputStream inputStream) throws IOException;
+}
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgeKeyValidator.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgeKeyValidator.java
new file mode 100644
index 0000000000..f6538c457d
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgeKeyValidator.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher.age;
+
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.PropertyValue;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.ValidationResult;
+import org.apache.nifi.components.Validator;
+import org.apache.nifi.components.resource.ResourceReference;
+import org.apache.nifi.components.resource.ResourceReferences;
+import org.apache.nifi.components.resource.ResourceType;
+import org.apache.nifi.components.resource.StandardResourceReferences;
+import org.apache.nifi.components.resource.Utf8TextResource;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.util.Collections;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.regex.Matcher;
+import java.util.stream.Collectors;
+
+/**
+ * Component Property Validator for age-encryption X25519 keys encoded using
Bech32
+ */
+public class AgeKeyValidator implements Validator {
+ private static final String FILE_RESOURCE_EXPLANATION = "File resource
validation passed";
+
+ private static final String RESOURCE_EXCEPTION = "Read failed: %s";
+
+ private static final String NOT_FOUND_EXPLANATION = "Encoded keys not
found";
+
+ private static final String INVALID_EXPLANATION = "Invalid keys found
[%d]";
+
+ private static final String VALID_EXPLANATION = "Valid keys found";
+
+ private static final String EXPLANATION_SEPARATOR = " and ";
+
+ private final AgeKeyIndicator ageKeyIndicator;
+ /**
+ * Key Validator with prefix for initial line filtering and pattern for
subsequent matching
+ *
+ * @param ageKeyIndicator Key Indicator
+ */
+ public AgeKeyValidator(final AgeKeyIndicator ageKeyIndicator) {
+ this.ageKeyIndicator = ageKeyIndicator;
+ }
+
+ /**
+ * Validate property reads one or more lines from a newline-delimited
string and requires at least one valid key
+ *
+ * @param subject Property to be validated
+ * @param input Property Value to be validated
+ * @param context Validation Context for property resolution
+ * @return Validation Result based on finding at least one valid key
+ */
+ @Override
+ public ValidationResult validate(final String subject, final String input,
final ValidationContext context) {
+ final PropertyValue propertyValue = getPropertyValue(subject, context);
+ ResourceReferences resources = propertyValue.asResources();
+
+ if (resources == null) {
+ final ResourceReference resourceReference = new
Utf8TextResource(input);
+ resources = new
StandardResourceReferences(Collections.singletonList(resourceReference));
+ }
+
+ final ValidationResult.Builder builder = new
ValidationResult.Builder();
+ builder.subject(subject);
+
+ final Set<ValidationResult> results =
resources.asList().stream().map(this::validateResource).collect(Collectors.toSet());
+ final String invalidExplanation = results.stream()
+ .filter(result -> !result.isValid())
+ .map(ValidationResult::getExplanation)
+ .collect(Collectors.joining(EXPLANATION_SEPARATOR));
+
+ if (invalidExplanation.isEmpty()) {
+ builder.explanation(VALID_EXPLANATION).valid(true);
+ } else {
+ builder.explanation(invalidExplanation).valid(false);
+ }
+
+ return builder.build();
+ }
+
+ private PropertyValue getPropertyValue(final String subject, final
ValidationContext context) {
+ final Optional<PropertyDescriptor> propertyFound =
context.getProperties()
+ .keySet()
+ .stream()
+ .filter(s -> s.getName().contentEquals(subject))
+ .findFirst();
+
+ final String message = String.format("Property [%s] not found",
subject);
+ final PropertyDescriptor propertyDescriptor =
propertyFound.orElseThrow(() -> new IllegalArgumentException(message));
+ return context.getProperty(propertyDescriptor);
+ }
+
+ private ValidationResult validateResource(final ResourceReference
resource) {
+ final ValidationResult.Builder builder = new
ValidationResult.Builder();
+
+ final ResourceType resourceType = resource.getResourceType();
+ if (ResourceType.TEXT == resourceType) {
+ try (final BufferedReader reader = new BufferedReader(new
InputStreamReader(resource.read()))) {
+ final Set<String> prefixedLines = reader.lines()
+ .filter(line ->
line.startsWith(ageKeyIndicator.getPrefix()))
+ .collect(Collectors.toSet());
+
+ final long invalid = prefixedLines.stream()
+ .map(ageKeyIndicator.getPattern()::matcher)
+ .filter(matcher -> !matcher.matches())
+ .count();
+
+ if (prefixedLines.size() == 0) {
+ builder.explanation(NOT_FOUND_EXPLANATION).valid(false);
+ } else if (invalid == 0) {
+ builder.explanation(VALID_EXPLANATION).valid(true);
+ } else {
+ final String explanation =
String.format(INVALID_EXPLANATION, invalid);
+ builder.explanation(explanation).valid(false);
+ }
+ } catch (final Exception e) {
+ final String explanation = String.format(RESOURCE_EXCEPTION,
e.getMessage());
+ builder.explanation(explanation).valid(false);
+ }
+ } else {
+ builder.explanation(FILE_RESOURCE_EXPLANATION).valid(true);
+ }
+
+ return builder.build();
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgePrivateKeyReader.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgePrivateKeyReader.java
new file mode 100644
index 0000000000..a89b6ac1c2
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgePrivateKeyReader.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher.age;
+
+import com.exceptionfactory.jagged.RecipientStanzaReader;
+import com.exceptionfactory.jagged.x25519.X25519RecipientStanzaReaderFactory;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.Provider;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * X25519 Private Key implementation age Key Reader
+ */
+public class AgePrivateKeyReader extends
AbstractAgeKeyReader<RecipientStanzaReader> {
+ private static final Provider KEY_PROVIDER =
AgeProviderResolver.getKeyProvider().orElse(null);
+
+ public AgePrivateKeyReader() {
+ super(AgeKeyIndicator.PRIVATE_KEY);
+ }
+
+ /**
+ * Read private keys and return Recipient Stanza Writers
+ *
+ * @param keys Set of private keys
+ * @return Recipient Stanza Writers
+ * @throws IOException Thrown on failures parsing private keys
+ */
+ @Override
+ protected List<RecipientStanzaReader> readKeys(final Set<String> keys)
throws IOException {
+ final List<RecipientStanzaReader> recipientStanzaReaders = new
ArrayList<>();
+ for (final String encodedPrivateKey : keys) {
+ try {
+ final RecipientStanzaReader recipientStanzaReader =
getRecipientStanzaReader(encodedPrivateKey);
+ recipientStanzaReaders.add(recipientStanzaReader);
+ } catch (final Exception e) {
+ throw new IOException("Parsing Private Key Identities failed",
e);
+ }
+ }
+
+ return recipientStanzaReaders;
+ }
+
+ private RecipientStanzaReader getRecipientStanzaReader(final String
encodedPrivateKey) throws GeneralSecurityException {
+ final RecipientStanzaReader recipientStanzaReader;
+
+ if (KEY_PROVIDER == null) {
+ recipientStanzaReader =
X25519RecipientStanzaReaderFactory.newRecipientStanzaReader(encodedPrivateKey);
+ } else {
+ recipientStanzaReader =
X25519RecipientStanzaReaderFactory.newRecipientStanzaReader(encodedPrivateKey,
KEY_PROVIDER);
+ }
+
+ return recipientStanzaReader;
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgeProviderResolver.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgeProviderResolver.java
new file mode 100644
index 0000000000..c7ca0bb27f
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgeProviderResolver.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher.age;
+
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+
+import java.security.Provider;
+import java.security.Security;
+import java.util.Optional;
+
+/**
+ * Resolver abstraction for age-encryption Security Algorithm Providers
+ */
+public final class AgeProviderResolver {
+ private static final String KEY_ALGORITHM_FILTER = "KeyFactory.X25519";
+
+ private static final String CIPHER_ALGORITHM_FILTER =
"Cipher.ChaCha20-Poly1305";
+
+ private AgeProviderResolver() {
+
+ }
+
+ /**
+ * Get Java Security Provider supporting ChaCha20-Poly1305
+ *
+ * @return Provider supporting ChaCha20-Poly1305 Cipher that defaults to
Bouncy Castle when no other Providers found
+ */
+ public static Provider getCipherProvider() {
+ final Provider cipherProvider;
+
+ final Provider[] providers =
Security.getProviders(CIPHER_ALGORITHM_FILTER);
+ if (providers == null) {
+ cipherProvider = new BouncyCastleProvider();
+ } else {
+ cipherProvider = providers[0];
+ }
+
+ return cipherProvider;
+ }
+
+ /**
+ * Get available Java Security Provider supporting X25519 and
ChaCha20-Poly1305 when registered Provider not found
+ *
+ * @return Empty Provider indicating platform support for X25519 Key
Factory or Bouncy Castle when no other Providers found
+ */
+ public static Optional<Provider> getKeyProvider() {
+ final Provider keyProvider;
+
+ final Provider[] providers =
Security.getProviders(KEY_ALGORITHM_FILTER);
+ if (providers == null) {
+ keyProvider = new BouncyCastleProvider();
+ } else {
+ keyProvider = null;
+ }
+
+ return Optional.ofNullable(keyProvider);
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgePublicKeyReader.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgePublicKeyReader.java
new file mode 100644
index 0000000000..ac0694d680
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/AgePublicKeyReader.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher.age;
+
+import com.exceptionfactory.jagged.RecipientStanzaWriter;
+import com.exceptionfactory.jagged.x25519.X25519RecipientStanzaWriterFactory;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.Provider;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * X25519 Public Key implementation age Key Reader
+ */
+public class AgePublicKeyReader extends
AbstractAgeKeyReader<RecipientStanzaWriter> {
+ private static final Provider KEY_PROVIDER =
AgeProviderResolver.getKeyProvider().orElse(null);
+
+ public AgePublicKeyReader() {
+ super(AgeKeyIndicator.PUBLIC_KEY);
+ }
+
+ /**
+ * Read public keys and return Recipient Stanza Writers
+ *
+ * @param keys Set of public keys
+ * @return Recipient Stanza Writers
+ * @throws IOException Thrown on failure to parse public keys
+ */
+ @Override
+ protected List<RecipientStanzaWriter> readKeys(final Set<String> keys)
throws IOException {
+ final List<RecipientStanzaWriter> recipientStanzaWriters = new
ArrayList<>();
+ for (final String encodedPublicKey : keys) {
+ try {
+ final RecipientStanzaWriter recipientStanzaWriter =
getRecipientStanzaWriter(encodedPublicKey);
+ recipientStanzaWriters.add(recipientStanzaWriter);
+ } catch (final Exception e) {
+ throw new IOException("Parsing Public Key Recipients failed",
e);
+ }
+ }
+
+ return recipientStanzaWriters;
+ }
+
+ private RecipientStanzaWriter getRecipientStanzaWriter(final String
encodedPublicKey) throws GeneralSecurityException {
+ final RecipientStanzaWriter recipientStanzaWriter;
+
+ if (KEY_PROVIDER == null) {
+ recipientStanzaWriter =
X25519RecipientStanzaWriterFactory.newRecipientStanzaWriter(encodedPublicKey);
+ } else {
+ recipientStanzaWriter =
X25519RecipientStanzaWriterFactory.newRecipientStanzaWriter(encodedPublicKey,
KEY_PROVIDER);
+ }
+
+ return recipientStanzaWriter;
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/FileEncoding.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/FileEncoding.java
new file mode 100644
index 0000000000..5be8ef94ed
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/FileEncoding.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher.age;
+
+import org.apache.nifi.components.DescribedValue;
+
+/**
+ * File Encoding options supporting Binary or ASCII Armor
+ */
+public enum FileEncoding implements DescribedValue {
+ BINARY("Binary encoding"),
+
+ ASCII("ASCII Armor encoding");
+
+ private final String description;
+
+ FileEncoding(final String description) {
+ this.description = description;
+ }
+
+ @Override
+ public String getValue() {
+ return name();
+ }
+
+ @Override
+ public String getDisplayName() {
+ return name();
+ }
+
+ @Override
+ public String getDescription() {
+ return description;
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/KeySource.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/KeySource.java
new file mode 100644
index 0000000000..8b5e762f2b
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/age/KeySource.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher.age;
+
+import org.apache.nifi.components.DescribedValue;
+
+/**
+ * Key resource loading strategy
+ */
+public enum KeySource implements DescribedValue {
+ PROPERTIES("Load one or more keys from configured properties"),
+
+ RESOURCES("Load one or more keys from files or URLs");
+
+ private final String description;
+
+ KeySource(final String description) {
+ this.description = description;
+ }
+
+ @Override
+ public String getValue() {
+ return name();
+ }
+
+ @Override
+ public String getDisplayName() {
+ return name();
+ }
+
+ @Override
+ public String getDescription() {
+ return description;
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/io/ChannelStreamCallback.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/io/ChannelStreamCallback.java
new file mode 100644
index 0000000000..a43fd6d710
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/java/org/apache/nifi/processors/cipher/io/ChannelStreamCallback.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher.io;
+
+import org.apache.nifi.processor.io.StreamCallback;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.Channels;
+import java.nio.channels.ReadableByteChannel;
+import java.nio.channels.WritableByteChannel;
+
+/**
+ * Channel Stream Callback wraps Java IO Streams in Java NIO Channels with
methods for customization of channels
+ */
+public class ChannelStreamCallback implements StreamCallback {
+ private static final int END_OF_FILE = -1;
+
+ private final int bufferCapacity;
+
+ /**
+ * Channel Stream Callback with configurable buffer capacity for
transferring bytes
+ *
+ * @param bufferCapacity Buffer capacity in bytes for transferring between
channels
+ */
+ public ChannelStreamCallback(final int bufferCapacity) {
+ this.bufferCapacity = bufferCapacity;
+ }
+
+ /**
+ * Process streams using NIO Channels with configured Buffer
+ *
+ * @param inputStream Input Stream to be wrapped using a Readable Byte
Channel
+ * @param outputStream Output Stream to be wrapped using a Writable Byte
Channel
+ * @throws IOException Thrown on read or write failures
+ */
+ @Override
+ public void process(final InputStream inputStream, final OutputStream
outputStream) throws IOException {
+ try (
+ final ReadableByteChannel readableByteChannel =
getReadableChannel(inputStream);
+ final WritableByteChannel outputChannel =
getWritableChannel(outputStream)
+ ) {
+ final ByteBuffer buffer = ByteBuffer.allocate(bufferCapacity);
+ while (readableByteChannel.read(buffer) != END_OF_FILE) {
+ buffer.flip();
+ while (buffer.hasRemaining()) {
+ outputChannel.write(buffer);
+ }
+ buffer.clear();
+ }
+
+ buffer.flip();
+ if (buffer.hasRemaining()) {
+ while (buffer.hasRemaining()) {
+ outputChannel.write(buffer);
+ }
+ }
+ }
+ }
+
+ /**
+ * Get Readable Byte Channel defaults to using Channels.newChannel()
+ *
+ * @param inputStream Input Stream to be wrapped
+ * @return Readable Byte Channel wrapping provided Input Stream
+ * @throws IOException Throw on channel initialization failures
+ */
+ protected ReadableByteChannel getReadableChannel(final InputStream
inputStream) throws IOException {
+ return Channels.newChannel(inputStream);
+ }
+
+ /**
+ * Get Writable Byte Channel defaults to using Channels.newChannel()
+ *
+ * @param outputStream Output Stream to be wrapped
+ * @return Writable Byte Channel wrapping provided Output Stream
+ * @throws IOException Thrown on channel initialization failures
+ */
+ protected WritableByteChannel getWritableChannel(final OutputStream
outputStream) throws IOException {
+ return Channels.newChannel(outputStream);
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
index 9952db3a9f..b3c1c3772c 100644
---
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
@@ -14,4 +14,6 @@
# limitations under the License.
org.apache.nifi.processors.cipher.DecryptContentCompatibility
org.apache.nifi.processors.cipher.DecryptContent
+org.apache.nifi.processors.cipher.DecryptContentAge
+org.apache.nifi.processors.cipher.EncryptContentAge
org.apache.nifi.processors.cipher.VerifyContentMAC
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/test/java/org/apache/nifi/processors/cipher/DecryptContentAgeTest.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/test/java/org/apache/nifi/processors/cipher/DecryptContentAgeTest.java
new file mode 100644
index 0000000000..f2149e15fd
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/test/java/org/apache/nifi/processors/cipher/DecryptContentAgeTest.java
@@ -0,0 +1,232 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher;
+
+import com.exceptionfactory.jagged.EncryptingChannelFactory;
+import com.exceptionfactory.jagged.RecipientStanzaWriter;
+import
com.exceptionfactory.jagged.framework.armor.ArmoredEncryptingChannelFactory;
+import
com.exceptionfactory.jagged.framework.stream.StandardEncryptingChannelFactory;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.processor.VerifiableProcessor;
+import org.apache.nifi.processors.cipher.age.AgeProviderResolver;
+import org.apache.nifi.processors.cipher.age.AgePublicKeyReader;
+import org.apache.nifi.processors.cipher.age.FileEncoding;
+import org.apache.nifi.processors.cipher.age.KeySource;
+import org.apache.nifi.util.MockFlowFile;
+import org.apache.nifi.util.TestRunner;
+import org.apache.nifi.util.TestRunners;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.opentest4j.AssertionFailedError;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.Channels;
+import java.nio.channels.WritableByteChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.security.Provider;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class DecryptContentAgeTest {
+
+ private static final byte[] WORD = {'W', 'O', 'R', 'D'};
+
+ private static final String CONTENT_WORD = new String(WORD);
+
+ private static final String PRIVATE_KEY_CHECKSUM_INVALID =
"AGE-SECRET-KEY-1NZKRS8L39Y2KVLXT7XX4DHFVDUUWN0699NJYR2EJS8RWGRYN279Q8GSFFF";
+
+ private static final String PRIVATE_KEY_ENCODED =
"AGE-SECRET-KEY-1NZKRS8L39Y2KVLXT7XX4DHFVDUUWN0699NJYR2EJS8RWGRYN279Q8GSFTN";
+
+ private static final String PRIVATE_KEY_SECOND =
"AGE-SECRET-KEY-1AU0T8M9GWJ4PEQK9TGS54T6VHRL8DLFTZ7AWYJDFTLMDZZWZQKDSA8K882";
+
+ private static final String PUBLIC_KEY_ENCODED =
"age1qqnete0p2wzcc7y55trm3c4jzr608g4xdvyfw9jurugyt6maauussgn5gg";
+
+ private static final String KEY_FILE_SUFFIX = ".key";
+
+ private TestRunner runner;
+
+ @BeforeEach
+ void setRunner() {
+ runner = TestRunners.newTestRunner(DecryptContentAge.class);
+ }
+
+ @Test
+ void testRequiredProperties() {
+ runner.assertNotValid();
+
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_IDENTITIES,
PRIVATE_KEY_ENCODED);
+
+ runner.assertValid();
+ }
+
+ @Test
+ void testPrivateKeyNotValid() {
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_IDENTITIES,
PUBLIC_KEY_ENCODED);
+
+ runner.assertNotValid();
+ }
+
+ @Test
+ void testVerifySuccessful() {
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_IDENTITIES,
PRIVATE_KEY_ENCODED);
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_SOURCE,
KeySource.PROPERTIES.getValue());
+
+
assertVerificationResultOutcomeEquals(ConfigVerificationResult.Outcome.SUCCESSFUL);
+ }
+
+ @Test
+ void testVerifyFailedChecksumInvalid() {
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_IDENTITIES,
PRIVATE_KEY_CHECKSUM_INVALID);
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_SOURCE,
KeySource.PROPERTIES.getValue());
+
+
assertVerificationResultOutcomeEquals(ConfigVerificationResult.Outcome.FAILED);
+ }
+
+ @Test
+ void testVerifyFailedResourcesNotConfigured() {
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_SOURCE,
KeySource.RESOURCES.getValue());
+
+
assertVerificationResultOutcomeEquals(ConfigVerificationResult.Outcome.FAILED);
+ }
+
+ @Test
+ void testRunSuccessAscii() throws GeneralSecurityException, IOException {
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_IDENTITIES,
PRIVATE_KEY_ENCODED);
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_SOURCE,
KeySource.PROPERTIES.getValue());
+
+ assertSuccess(FileEncoding.ASCII);
+ }
+
+ @Test
+ void testRunSuccessBinary() throws GeneralSecurityException, IOException {
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_IDENTITIES,
PRIVATE_KEY_ENCODED);
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_SOURCE,
KeySource.PROPERTIES.getValue());
+
+ assertSuccess(FileEncoding.BINARY);
+ }
+
+ @Test
+ void testRunSuccessBinaryMultiplePrivateKeys() throws
GeneralSecurityException, IOException {
+ final String multiplePrivateKeys = String.format("%s%n%s",
PRIVATE_KEY_ENCODED, PRIVATE_KEY_SECOND);
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_IDENTITIES,
multiplePrivateKeys);
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_SOURCE,
KeySource.PROPERTIES.getValue());
+
+ assertSuccess(FileEncoding.BINARY);
+ }
+
+ @Test
+ void testRunSuccessBinaryKeySourceResources(@TempDir final Path tempDir)
throws GeneralSecurityException, IOException {
+ final Path tempFile = createTempFile(tempDir);
+ Files.write(tempFile,
PRIVATE_KEY_ENCODED.getBytes(StandardCharsets.UTF_8));
+
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_IDENTITY_RESOURCES,
tempFile.toString());
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_SOURCE,
KeySource.RESOURCES.getValue());
+
+ assertSuccess(FileEncoding.BINARY);
+ }
+
+ @Test
+ void testRunScheduledErrorPrivateKeyNotFound(@TempDir final Path tempDir)
throws GeneralSecurityException, IOException {
+ final Path tempFile = createTempFile(tempDir);
+
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_IDENTITY_RESOURCES,
tempFile.toString());
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_SOURCE,
KeySource.RESOURCES.getValue());
+
+ final byte[] encrypted = getEncrypted(FileEncoding.BINARY);
+ runner.enqueue(encrypted);
+
+ assertThrows(AssertionFailedError.class, runner::run);
+ }
+
+ @Test
+ void testRunFailure() {
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_IDENTITIES,
PRIVATE_KEY_ENCODED);
+ runner.setProperty(DecryptContentAge.PRIVATE_KEY_SOURCE,
KeySource.PROPERTIES.getValue());
+
+ runner.enqueue(WORD);
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(DecryptContentAge.FAILURE);
+ }
+
+ private void assertSuccess(final FileEncoding fileEncoding) throws
GeneralSecurityException, IOException {
+ final byte[] encrypted = getEncrypted(fileEncoding);
+ runner.enqueue(encrypted);
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(DecryptContentAge.SUCCESS);
+
+ final Iterator<MockFlowFile> flowFiles =
runner.getFlowFilesForRelationship(DecryptContentAge.SUCCESS).iterator();
+ assertTrue(flowFiles.hasNext());
+ final MockFlowFile flowFile = flowFiles.next();
+
+ final String content = flowFile.getContent();
+ assertEquals(CONTENT_WORD, content);
+ }
+
+ private void assertVerificationResultOutcomeEquals(final
ConfigVerificationResult.Outcome expected) {
+ final VerifiableProcessor processor = (VerifiableProcessor)
runner.getProcessor();
+ final Iterator<ConfigVerificationResult> results =
processor.verify(runner.getProcessContext(), runner.getLogger(),
Collections.emptyMap()).iterator();
+
+ assertTrue(results.hasNext());
+ final ConfigVerificationResult result = results.next();
+ assertEquals(expected, result.getOutcome());
+ }
+
+ private byte[] getEncrypted(final FileEncoding fileEncoding) throws
GeneralSecurityException, IOException {
+ final EncryptingChannelFactory encryptingChannelFactory =
getEncryptingChannelFactory(fileEncoding);
+ final AgePublicKeyReader publicKeyReader = new AgePublicKeyReader();
+ final ByteArrayInputStream encodedInputStream = new
ByteArrayInputStream(PUBLIC_KEY_ENCODED.getBytes(StandardCharsets.UTF_8));
+ final List<RecipientStanzaWriter> recipientStanzaWriters =
publicKeyReader.read(encodedInputStream);
+
+ final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ final WritableByteChannel outputChannel =
Channels.newChannel(outputStream);
+ try (final WritableByteChannel encryptingChannel =
encryptingChannelFactory.newEncryptingChannel(outputChannel,
recipientStanzaWriters)) {
+ final ByteBuffer buffer = ByteBuffer.wrap(WORD);
+ encryptingChannel.write(buffer);
+ }
+
+ return outputStream.toByteArray();
+ }
+
+ private EncryptingChannelFactory getEncryptingChannelFactory(final
FileEncoding fileEncoding) {
+ final Provider provider = AgeProviderResolver.getCipherProvider();
+ final EncryptingChannelFactory encryptingChannelFactory;
+ if (FileEncoding.ASCII == fileEncoding) {
+ encryptingChannelFactory = new
ArmoredEncryptingChannelFactory(provider);
+ } else {
+ encryptingChannelFactory = new
StandardEncryptingChannelFactory(provider);
+ }
+ return encryptingChannelFactory;
+ }
+
+ private Path createTempFile(final Path tempDir) throws IOException {
+ return Files.createTempFile(tempDir,
DecryptContentAgeTest.class.getSimpleName(), KEY_FILE_SUFFIX);
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/test/java/org/apache/nifi/processors/cipher/EncryptContentAgeTest.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/test/java/org/apache/nifi/processors/cipher/EncryptContentAgeTest.java
new file mode 100644
index 0000000000..450a456c8c
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/test/java/org/apache/nifi/processors/cipher/EncryptContentAgeTest.java
@@ -0,0 +1,246 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher;
+
+import com.exceptionfactory.jagged.DecryptingChannelFactory;
+import com.exceptionfactory.jagged.RecipientStanzaReader;
+import
com.exceptionfactory.jagged.framework.armor.ArmoredDecryptingChannelFactory;
+import
com.exceptionfactory.jagged.framework.stream.StandardDecryptingChannelFactory;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.processor.VerifiableProcessor;
+import org.apache.nifi.processors.cipher.age.AgePrivateKeyReader;
+import org.apache.nifi.processors.cipher.age.AgeProviderResolver;
+import org.apache.nifi.processors.cipher.age.FileEncoding;
+import org.apache.nifi.processors.cipher.age.KeySource;
+import org.apache.nifi.util.MockFlowFile;
+import org.apache.nifi.util.TestRunner;
+import org.apache.nifi.util.TestRunners;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.opentest4j.AssertionFailedError;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.Channels;
+import java.nio.channels.ReadableByteChannel;
+import java.nio.channels.WritableByteChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.security.Provider;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class EncryptContentAgeTest {
+
+ private static final String BINARY_VERSION = "age-encryption.org";
+
+ private static final String ASCII_HEADER = "-----BEGIN AGE ENCRYPTED
FILE-----";
+
+ private static final byte[] WORD = {'W', 'O', 'R', 'D'};
+
+ private static final int BUFFER_CAPACITY = 20;
+
+ private static final String PRIVATE_KEY_ENCODED =
"AGE-SECRET-KEY-1NZKRS8L39Y2KVLXT7XX4DHFVDUUWN0699NJYR2EJS8RWGRYN279Q8GSFTN";
+
+ private static final String PUBLIC_KEY_ENCODED =
"age1qqnete0p2wzcc7y55trm3c4jzr608g4xdvyfw9jurugyt6maauussgn5gg";
+
+ private static final String PUBLIC_KEY_CHECKSUM_INVALID =
"age1qqnete0p2wzcc7y55trm3c4jzr608g4xdvyfw9jurugyt6maauussgn000";
+
+ private static final String KEY_FILE_SUFFIX = ".key";
+
+ private TestRunner runner;
+
+ @BeforeEach
+ void setRunner() {
+ runner = TestRunners.newTestRunner(EncryptContentAge.class);
+ }
+
+ @Test
+ void testRequiredProperties() {
+ runner.assertNotValid();
+
+ runner.setProperty(EncryptContentAge.PUBLIC_KEY_RECIPIENTS,
PUBLIC_KEY_ENCODED);
+
+ runner.assertValid();
+ }
+
+ @Test
+ void testPublicKeyNotValid() {
+ runner.setProperty(EncryptContentAge.PUBLIC_KEY_RECIPIENTS,
PRIVATE_KEY_ENCODED);
+
+ runner.assertNotValid();
+ }
+
+ @Test
+ void testVerifySuccessful() {
+ runner.setProperty(EncryptContentAge.PUBLIC_KEY_RECIPIENTS,
PUBLIC_KEY_ENCODED);
+ runner.setProperty(EncryptContentAge.PUBLIC_KEY_SOURCE,
KeySource.PROPERTIES.getValue());
+
+
assertVerificationResultOutcomeEquals(ConfigVerificationResult.Outcome.SUCCESSFUL);
+ }
+
+ @Test
+ void testVerifyFailedChecksumInvalid() {
+ runner.setProperty(EncryptContentAge.PUBLIC_KEY_RECIPIENTS,
PUBLIC_KEY_CHECKSUM_INVALID);
+ runner.setProperty(EncryptContentAge.PUBLIC_KEY_SOURCE,
KeySource.PROPERTIES.getValue());
+
+
assertVerificationResultOutcomeEquals(ConfigVerificationResult.Outcome.FAILED);
+ }
+
+ @Test
+ void testVerifyFailedResourcesNotFound() {
+ runner.setProperty(EncryptContentAge.PUBLIC_KEY_SOURCE,
KeySource.RESOURCES.getValue());
+
+
assertVerificationResultOutcomeEquals(ConfigVerificationResult.Outcome.FAILED);
+ }
+
+ @Test
+ void testRunSuccessAscii() throws GeneralSecurityException, IOException {
+ runner.setProperty(EncryptContentAge.PUBLIC_KEY_RECIPIENTS,
PUBLIC_KEY_ENCODED);
+ runner.setProperty(EncryptContentAge.PUBLIC_KEY_SOURCE,
KeySource.PROPERTIES.getValue());
+ runner.setProperty(EncryptContentAge.FILE_ENCODING,
FileEncoding.ASCII.getValue());
+
+ runner.enqueue(WORD);
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(EncryptContentAge.SUCCESS);
+ assertAsciiFound();
+ }
+
+ @Test
+ void testRunSuccessBinary() throws GeneralSecurityException, IOException {
+ runner.setProperty(EncryptContentAge.PUBLIC_KEY_RECIPIENTS,
PUBLIC_KEY_ENCODED);
+ runner.setProperty(EncryptContentAge.PUBLIC_KEY_SOURCE,
KeySource.PROPERTIES.getValue());
+ runner.setProperty(EncryptContentAge.FILE_ENCODING,
FileEncoding.BINARY.getValue());
+
+ runner.enqueue(WORD);
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(EncryptContentAge.SUCCESS);
+ assertBinaryFound();
+ }
+
+ @Test
+ void testRunSuccessBinaryKeySourceResources(@TempDir final Path tempDir)
throws GeneralSecurityException, IOException {
+ final Path tempFile = createTempFile(tempDir);
+ Files.write(tempFile,
PUBLIC_KEY_ENCODED.getBytes(StandardCharsets.UTF_8));
+
+ runner.setProperty(EncryptContentAge.PUBLIC_KEY_RECIPIENT_RESOURCES,
tempFile.toString());
+ runner.setProperty(EncryptContentAge.PUBLIC_KEY_SOURCE,
KeySource.RESOURCES.getValue());
+ runner.setProperty(EncryptContentAge.FILE_ENCODING,
FileEncoding.BINARY.getValue());
+
+ runner.enqueue(WORD);
+ runner.run();
+
+ runner.assertAllFlowFilesTransferred(EncryptContentAge.SUCCESS);
+ assertBinaryFound();
+ }
+
+ @Test
+ void testRunScheduledErrorPublicKeyNotFound(@TempDir final Path tempDir)
throws IOException {
+ final Path tempFile = createTempFile(tempDir);
+
+ runner.setProperty(EncryptContentAge.PUBLIC_KEY_RECIPIENT_RESOURCES,
tempFile.toString());
+ runner.setProperty(EncryptContentAge.PUBLIC_KEY_SOURCE,
KeySource.RESOURCES.getValue());
+
+ runner.enqueue(WORD);
+
+ assertThrows(AssertionFailedError.class, runner::run);
+ }
+
+ private void assertBinaryFound() throws GeneralSecurityException,
IOException {
+ final Iterator<MockFlowFile> flowFiles =
runner.getFlowFilesForRelationship(EncryptContentAge.SUCCESS).iterator();
+
+ assertTrue(flowFiles.hasNext());
+
+ final MockFlowFile flowFile = flowFiles.next();
+ final String content = flowFile.getContent();
+
+ assertTrue(content.startsWith(BINARY_VERSION));
+
+ final byte[] decrypted = getDecrypted(FileEncoding.BINARY,
flowFile.getContentStream());
+ assertArrayEquals(WORD, decrypted);
+ }
+
+ private void assertAsciiFound() throws GeneralSecurityException,
IOException {
+ final Iterator<MockFlowFile> flowFiles =
runner.getFlowFilesForRelationship(EncryptContentAge.SUCCESS).iterator();
+
+ assertTrue(flowFiles.hasNext());
+
+ final MockFlowFile flowFile = flowFiles.next();
+ final String content = flowFile.getContent();
+
+ assertTrue(content.startsWith(ASCII_HEADER));
+
+ final byte[] decrypted = getDecrypted(FileEncoding.ASCII,
flowFile.getContentStream());
+ assertArrayEquals(WORD, decrypted);
+ }
+
+ private void assertVerificationResultOutcomeEquals(final
ConfigVerificationResult.Outcome expected) {
+ final VerifiableProcessor processor = (VerifiableProcessor)
runner.getProcessor();
+ final Iterator<ConfigVerificationResult> results =
processor.verify(runner.getProcessContext(), runner.getLogger(),
Collections.emptyMap()).iterator();
+
+ assertTrue(results.hasNext());
+ final ConfigVerificationResult result = results.next();
+ assertEquals(expected, result.getOutcome());
+ }
+
+ private byte[] getDecrypted(final FileEncoding fileEncoding, final
InputStream inputStream) throws GeneralSecurityException, IOException {
+ final DecryptingChannelFactory decryptingChannelFactory =
getDecryptingChannelFactory(fileEncoding);
+ final AgePrivateKeyReader privateKeyReader = new AgePrivateKeyReader();
+ final ByteArrayInputStream encodedInputStream = new
ByteArrayInputStream(PRIVATE_KEY_ENCODED.getBytes(StandardCharsets.UTF_8));
+ final List<RecipientStanzaReader> recipientStanzaReaders =
privateKeyReader.read(encodedInputStream);
+
+ final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ final WritableByteChannel outputChannel =
Channels.newChannel(outputStream);
+ try (final ReadableByteChannel decryptingChannel =
decryptingChannelFactory.newDecryptingChannel(Channels.newChannel(inputStream),
recipientStanzaReaders)) {
+ final ByteBuffer buffer = ByteBuffer.allocate(BUFFER_CAPACITY);
+ decryptingChannel.read(buffer);
+ buffer.flip();
+ outputChannel.write(buffer);
+ }
+
+ return outputStream.toByteArray();
+ }
+
+ private DecryptingChannelFactory getDecryptingChannelFactory(final
FileEncoding fileEncoding) {
+ final Provider provider = AgeProviderResolver.getCipherProvider();
+ final DecryptingChannelFactory decryptingChannelFactory;
+ if (FileEncoding.ASCII == fileEncoding) {
+ decryptingChannelFactory = new
ArmoredDecryptingChannelFactory(provider);
+ } else {
+ decryptingChannelFactory = new
StandardDecryptingChannelFactory(provider);
+ }
+ return decryptingChannelFactory;
+ }
+
+ private Path createTempFile(final Path tempDir) throws IOException {
+ return Files.createTempFile(tempDir,
EncryptContentAgeTest.class.getSimpleName(), KEY_FILE_SUFFIX);
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/test/java/org/apache/nifi/processors/cipher/age/AgePrivateKeyReaderTest.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/test/java/org/apache/nifi/processors/cipher/age/AgePrivateKeyReaderTest.java
new file mode 100644
index 0000000000..70acad6bf3
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/test/java/org/apache/nifi/processors/cipher/age/AgePrivateKeyReaderTest.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher.age;
+
+import com.exceptionfactory.jagged.RecipientStanzaReader;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Iterator;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AgePrivateKeyReaderTest {
+ private static final String PRIVATE_KEY_ENCODED =
"AGE-SECRET-KEY-1NZKRS8L39Y2KVLXT7XX4DHFVDUUWN0699NJYR2EJS8RWGRYN279Q8GSFTN";
+
+ private static final String PRIVATE_KEY_CHECKSUM_INVALID =
"AGE-SECRET-KEY-1NZKRS8L39Y2KVLXT7XX4DHFVDUUWN0699NJYR2EJS8RWGRYN279Q8GSFFF";
+
+ private AgePrivateKeyReader reader;
+
+ @BeforeEach
+ void setReader() {
+ reader = new AgePrivateKeyReader();
+ }
+
+ @Test
+ void testRead() throws IOException {
+ final InputStream inputStream = new
ByteArrayInputStream(PRIVATE_KEY_ENCODED.getBytes(StandardCharsets.UTF_8));
+
+ final Iterator<RecipientStanzaReader> recipientStanzaReaders =
reader.read(inputStream).iterator();
+
+ assertTrue(recipientStanzaReaders.hasNext());
+
+ final RecipientStanzaReader recipientStanzaReader =
recipientStanzaReaders.next();
+ assertNotNull(recipientStanzaReader);
+
+ assertFalse(recipientStanzaReaders.hasNext());
+ }
+
+ @Test
+ void testReadChecksumInvalid() {
+ final InputStream inputStream = new
ByteArrayInputStream(PRIVATE_KEY_CHECKSUM_INVALID.getBytes(StandardCharsets.UTF_8));
+
+ assertThrows(IOException.class, () -> reader.read(inputStream));
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/test/java/org/apache/nifi/processors/cipher/age/AgePublicKeyReaderTest.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/test/java/org/apache/nifi/processors/cipher/age/AgePublicKeyReaderTest.java
new file mode 100644
index 0000000000..c44d677b3d
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/test/java/org/apache/nifi/processors/cipher/age/AgePublicKeyReaderTest.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher.age;
+
+import com.exceptionfactory.jagged.RecipientStanzaWriter;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Iterator;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class AgePublicKeyReaderTest {
+ private static final String PUBLIC_KEY_ENCODED =
"age1qqnete0p2wzcc7y55trm3c4jzr608g4xdvyfw9jurugyt6maauussgn5gg";
+
+ private static final String PUBLIC_KEY_CHECKSUM_INVALID =
"age1qqnete0p2wzcc7y55trm3c4jzr608g4xdvyfw9jurugyt6maauussgn000";
+
+ private AgePublicKeyReader reader;
+
+ @BeforeEach
+ void setReader() {
+ reader = new AgePublicKeyReader();
+ }
+
+ @Test
+ void testRead() throws IOException {
+ final InputStream inputStream = new
ByteArrayInputStream(PUBLIC_KEY_ENCODED.getBytes(StandardCharsets.UTF_8));
+
+ final Iterator<RecipientStanzaWriter> recipientStanzaWriters =
reader.read(inputStream).iterator();
+
+ assertTrue(recipientStanzaWriters.hasNext());
+
+ final RecipientStanzaWriter recipientStanzaWriter =
recipientStanzaWriters.next();
+ assertNotNull(recipientStanzaWriter);
+
+ assertFalse(recipientStanzaWriters.hasNext());
+ }
+
+ @Test
+ void testReadChecksumInvalid() {
+ final InputStream inputStream = new
ByteArrayInputStream(PUBLIC_KEY_CHECKSUM_INVALID.getBytes(StandardCharsets.UTF_8));
+
+ assertThrows(IOException.class, () -> reader.read(inputStream));
+ }
+}
diff --git
a/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/test/java/org/apache/nifi/processors/cipher/io/ChannelStreamCallbackTest.java
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/test/java/org/apache/nifi/processors/cipher/io/ChannelStreamCallbackTest.java
new file mode 100644
index 0000000000..0f01d7b861
--- /dev/null
+++
b/nifi-nar-bundles/nifi-cipher-bundle/nifi-cipher-processors/src/test/java/org/apache/nifi/processors/cipher/io/ChannelStreamCallbackTest.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.cipher.io;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.channels.ReadableByteChannel;
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+
+class ChannelStreamCallbackTest {
+ private static final int BUFFER_CAPACITY = 8192;
+
+ private static final int BUFFER_CAPACITY_DOUBLED = 16384;
+
+ private static final byte BYTE = 7;
+
+ private static final int END_OF_FILE = -1;
+
+ @Test
+ void testProcessEmpty() throws IOException {
+ final byte[] expected = new byte[]{};
+
+ assertProcessSuccess(expected);
+ }
+
+ @Test
+ void testProcessSingleByte() throws IOException {
+ final byte[] expected = new byte[]{BYTE};
+
+ assertProcessSuccess(expected);
+ }
+
+ @Test
+ void testProcessBufferCapacity() throws IOException {
+ final byte[] expected = new byte[BUFFER_CAPACITY];
+ Arrays.fill(expected, BYTE);
+
+ assertProcessSuccess(expected);
+ }
+
+ @Test
+ void testProcessBufferCapacityDoubled() throws IOException {
+ final byte[] expected = new byte[BUFFER_CAPACITY_DOUBLED];
+ Arrays.fill(expected, BYTE);
+
+ assertProcessSuccess(expected);
+ }
+
+ @Test
+ void testProcessReadBufferRemaining() throws IOException {
+ final ChannelStreamCallback callback = new
BufferRemainingChannelStreamCallback();
+
+ final byte[] expected = new byte[]{BYTE};
+ final ByteArrayInputStream inputStream = new
ByteArrayInputStream(expected);
+ final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+
+ callback.process(inputStream, outputStream);
+
+ assertArrayEquals(expected, outputStream.toByteArray());
+ }
+
+ private void assertProcessSuccess(final byte[] expected) throws
IOException {
+ final ChannelStreamCallback callback = new
ChannelStreamCallback(BUFFER_CAPACITY);
+
+ final ByteArrayInputStream inputStream = new
ByteArrayInputStream(expected);
+ final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+
+ callback.process(inputStream, outputStream);
+
+ assertArrayEquals(expected, outputStream.toByteArray());
+ }
+
+ private static class BufferRemainingChannelStreamCallback extends
ChannelStreamCallback {
+
+ public BufferRemainingChannelStreamCallback() {
+ super(BUFFER_CAPACITY);
+ }
+
+ protected ReadableByteChannel getReadableChannel(final InputStream
inputStream) {
+ return new BufferRemainingReadableByteChannel();
+ }
+ }
+
+ private static class BufferRemainingReadableByteChannel implements
ReadableByteChannel {
+
+ @Override
+ public int read(final ByteBuffer buffer) {
+ buffer.put(BYTE);
+ return END_OF_FILE;
+ }
+
+ @Override
+ public boolean isOpen() {
+ return true;
+ }
+
+ @Override
+ public void close() {
+
+ }
+ }
+}
diff --git a/nifi-nar-bundles/nifi-cipher-bundle/pom.xml
b/nifi-nar-bundles/nifi-cipher-bundle/pom.xml
index ff178cbfa5..70a3e5ffce 100644
--- a/nifi-nar-bundles/nifi-cipher-bundle/pom.xml
+++ b/nifi-nar-bundles/nifi-cipher-bundle/pom.xml
@@ -29,4 +29,16 @@
<module>nifi-cipher-processors</module>
<module>nifi-cipher-nar</module>
</modules>
+
+ <dependencyManagement>
+ <dependencies>
+ <dependency>
+ <groupId>com.exceptionfactory.jagged</groupId>
+ <artifactId>jagged-bom</artifactId>
+ <version>0.2.0</version>
+ <scope>import</scope>
+ <type>pom</type>
+ </dependency>
+ </dependencies>
+ </dependencyManagement>
</project>