diff --git a/.github/references/ubuntu_22_04_clang_arm_manifest.json b/.github/references/ubuntu_22_04_clang_arm_manifest.json
index 54225fe9c8..ff474ed25a 100644
--- a/.github/references/ubuntu_22_04_clang_arm_manifest.json
+++ b/.github/references/ubuntu_22_04_clang_arm_manifest.json
@@ -12059,6 +12059,203 @@
"version": "1.0.0"
}
},
+{
+ "bundles": {
+ "componentManifest": {
+ "processors": [
+ {
+ "propertyDescriptors": {
+ "Decryption Strategy": {
+ "name": "Decryption Strategy",
+ "description": "Strategy for writing files to success after decryption",
+ "validator": "VALID",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "DECRYPTED",
+ "allowableValues": [
+ {
+ "value": "DECRYPTED",
+ "displayName": "DECRYPTED"
+ },
+ {
+ "value": "PACKAGED",
+ "displayName": "PACKAGED"
+ }
+ ]
+ },
+ "Private Key Service": {
+ "typeProvidedByValue": {
+ "type": "minifi_pgp.controller_services.private_key_service.PGPPrivateKeyService",
+ "group": "org.apache.nifi.minifi.rust",
+ "artifact": "minifi_pgp"
+ },
+ "name": "Private Key Service",
+ "description": "PGP Private Key Service for decrypting data encrypted with Public Key Encryption",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Symmetric Password": {
+ "name": "Symmetric Password",
+ "description": "Password used for decrypting data encrypted with Password-Based Encryption",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "true",
+ "expressionLanguageScope": "NONE"
+ }
+ },
+ "inputRequirement": "INPUT_REQUIRED",
+ "isSingleThreaded": "false",
+ "supportedRelationships": [
+ {
+ "name": "failure",
+ "description": "Decryption Failed"
+ },
+ {
+ "name": "success",
+ "description": "Decryption Succeeded"
+ }
+ ],
+ "typeDescription": "Decrypt contents of OpenPGP messages. Using the Packaged Decryption Strategy preserves OpenPGP encoding to support subsequent signature verification.",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_pgp.processors.decrypt_content.DecryptContentPGP"
+ },
+ {
+ "propertyDescriptors": {
+ "File Encoding": {
+ "name": "File Encoding",
+ "description": "File Encoding for encryption",
+ "validator": "VALID",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "BINARY",
+ "allowableValues": [
+ {
+ "value": "ASCII",
+ "displayName": "ASCII"
+ },
+ {
+ "value": "BINARY",
+ "displayName": "BINARY"
+ }
+ ]
+ },
+ "Public Key Search": {
+ "name": "Public Key Search",
+ "description": "PGP Public Key Search will be used to match against the User ID or Key ID when formatted as uppercase hexadecimal string of 16 characters",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "FLOWFILE_ATTRIBUTES"
+ },
+ "Public Key Service": {
+ "typeProvidedByValue": {
+ "type": "minifi_pgp.controller_services.public_key_service.PGPPublicKeyService",
+ "group": "org.apache.nifi.minifi.rust",
+ "artifact": "minifi_pgp"
+ },
+ "name": "Public Key Service",
+ "description": "PGP Public Key Service for encrypting data with Public Key Encryption",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Symmetric Password": {
+ "name": "Symmetric Password",
+ "description": "Password used for encrypting data with Password-Based Encryption",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "true",
+ "expressionLanguageScope": "NONE"
+ }
+ },
+ "inputRequirement": "INPUT_REQUIRED",
+ "isSingleThreaded": "false",
+ "supportedRelationships": [
+ {
+ "name": "failure",
+ "description": "Encryption Failed"
+ },
+ {
+ "name": "success",
+ "description": "Encryption Succeeded"
+ }
+ ],
+ "typeDescription": "Encrypt contents using OpenPGP.",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_pgp.processors.encrypt_content.EncryptContentPGP"
+ }
+ ],
+ "controllerServices": [
+ {
+ "propertyDescriptors": {
+ "Key": {
+ "name": "Key",
+ "description": "Secret Key encoded in ASCII Armor",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "true",
+ "expressionLanguageScope": "NONE"
+ },
+ "Key File": {
+ "name": "Key File",
+ "description": "File path to PGP Secret Key encoded in binary or ASCII Armor",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "FLOWFILE_ATTRIBUTES"
+ },
+ "Key Passphrase": {
+ "name": "Key Passphrase",
+ "description": "Passphrase used for decrypting Private Keys",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "true",
+ "expressionLanguageScope": "NONE"
+ }
+ },
+ "typeDescription": "PGP Private Key Service provides Private Keys loaded from files or properties",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_pgp.controller_services.private_key_service.PGPPrivateKeyService"
+ },
+ {
+ "propertyDescriptors": {
+ "Keyring": {
+ "name": "Keyring",
+ "description": "PGP Keyring or Secret Key encoded in ASCII Armor",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "true",
+ "expressionLanguageScope": "NONE"
+ },
+ "Keyring File": {
+ "name": "Keyring File",
+ "description": "File path to PGP Keyring or Secret Key encoded in binary or ASCII Armor",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "FLOWFILE_ATTRIBUTES"
+ }
+ },
+ "typeDescription": "PGP Public Key Service providing Public Keys loaded from files",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_pgp.controller_services.public_key_service.PGPPublicKeyService"
+ }
+ ]
+ },
+ "group": "org.apache.nifi.minifi.rust",
+ "artifact": "minifi_pgp",
+ "version": "1.0.0"
+ }
+},
{
"bundles": {
"componentManifest": {
diff --git a/minifi_rust/extensions/minifi_pgp/.cargo/config.toml b/minifi_rust/extensions/minifi_pgp/.cargo/config.toml
new file mode 100644
index 0000000000..cb8c02ddc4
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/.cargo/config.toml
@@ -0,0 +1,5 @@
+[target.aarch64-apple-darwin]
+rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"]
+
+[target.x86_64-apple-darwin]
+rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"]
diff --git a/minifi_rust/extensions/minifi_pgp/.gitignore b/minifi_rust/extensions/minifi_pgp/.gitignore
new file mode 100644
index 0000000000..f9f6d205fa
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/.gitignore
@@ -0,0 +1,7 @@
+target
+output
+features/.venv
+features/output
+integration_tests/features/.venv
+integration_tests/features/linux_so
+integration_tests/.venv
\ No newline at end of file
diff --git a/minifi_rust/extensions/minifi_pgp/Cargo.toml b/minifi_rust/extensions/minifi_pgp/Cargo.toml
new file mode 100644
index 0000000000..6edd4d3b72
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "minifi_pgp"
+version = "1.0.0"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+minifi_native = { path = "../../minifi_native" }
+strum_macros = "0.28.0"
+strum = "0.28.0"
+pgp = "0.20.0"
+rand = "0.8.6" # pgp 0.20.0 doesnt support >= 0.9 rand yet
diff --git a/minifi_rust/extensions/minifi_pgp/features/encrypt_decrypt.feature b/minifi_rust/extensions/minifi_pgp/features/encrypt_decrypt.feature
new file mode 100644
index 0000000000..3914e71308
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/features/encrypt_decrypt.feature
@@ -0,0 +1,54 @@
+@SUPPORTS_WINDOWS
+Feature: Test PGP extension's encryption and decryption capabilities
+
+ Background: The pgp library is successfully built on linux
+
+ Scenario: The pgp library is loaded into minifi
+ Given log property "logger.org::apache::nifi::minifi::core::extension::ExtensionManager" is set to "TRACE,stderr"
+ And log property "logger.org::apache::nifi::minifi::core::ClassLoader" is set to "TRACE,stderr"
+
+ When the MiNiFi instance starts up
+
+ Then the Minifi logs contain the following message: "Registering class 'EncryptContentPGP' at '/minifi_pgp'" in less than 10 seconds
+ And the Minifi logs contain the following message: "Registering class 'DecryptContentPGP' at '/minifi_pgp'" in less than 1 seconds
+ And the Minifi logs contain the following message: "Registering class 'PGPPublicKeyService' at '/minifi_pgp'" in less than 1 seconds
+ And the Minifi logs contain the following message: "Registering class 'PGPPrivateKeyService' at '/minifi_pgp'" in less than 1 seconds
+ And the Minifi logs do not contain errors
+ And the Minifi logs do not contain warnings
+
+ Scenario: Encrypted for Alice but not for Bob
+ Given log property "logger.minifi_pgp::processors::decrypt_content::DecryptContentPGP" is set to "TRACE,stderr"
+ And log property "logger.minifi_pgp::processors::encrypt_content::EncryptContentPGP" is set to "TRACE,stderr"
+
+ And a GetFile processor with the "Input Directory" property set to "/tmp/input"
+ And an EncryptContentPGP processor with a PGPPublicKeyService is set up
+ And a DecryptContentPGP processor named DecryptAlice with a PGPPrivateKeyService is set up for Alice
+ And a DecryptContentPGP processor named DecryptBob with a PGPPrivateKeyService is set up for Bob
+ And a PutFile processor with the name "AliceSuccess"
+ And a PutFile processor with the name "BobFailure"
+
+ And these processor properties are set
+ | processor name | property name | property value |
+ | EncryptContentPGP | File Encoding | ASCII |
+ | EncryptContentPGP | Public Key Search | Alice |
+ | AliceSuccess | Directory | /tmp/output/alice_ok |
+ | BobFailure | Directory | /tmp/output/bob_fail |
+
+ And the processors are connected up as described here
+ | source name | relationship name | destination name |
+ | GetFile | success | EncryptContentPGP |
+ | EncryptContentPGP | success | DecryptAlice |
+ | EncryptContentPGP | success | DecryptBob |
+ | DecryptAlice | success | AliceSuccess |
+ | DecryptBob | failure | BobFailure |
+
+ And AliceSuccess's success relationship is auto-terminated
+ And BobFailure's success relationship is auto-terminated
+
+ And a directory at "/tmp/input" has a file "test_file.log" with the content "test content"
+
+ When the MiNiFi instance starts up
+
+ Then at least one file with the content "test content" is placed in the "/tmp/output/alice_ok" directory in less than 5 seconds
+ And an encrypted armored pgp file is placed in the "/tmp/output/bob_fail" directory in less than 5 seconds
+ And the Minifi logs do not contain errors
diff --git a/minifi_rust/extensions/minifi_pgp/features/environment.py b/minifi_rust/extensions/minifi_pgp/features/environment.py
new file mode 100644
index 0000000000..cdec449069
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/features/environment.py
@@ -0,0 +1,73 @@
+import os
+from typing import List
+
+from minifi_behave.containers.docker_image_builder import DockerImageBuilder
+from minifi_behave.core.hooks import common_after_scenario
+from minifi_behave.core.hooks import common_before_scenario, get_minifi_container_image
+from minifi_behave.core.minifi_test_context import MinifiTestContext
+
+
+def add_extension_to_minifi_container(
+ extension_name: str, possible_paths: List[str], context: MinifiTestContext
+):
+ new_container_name = f"apacheminificpp:{extension_name}"
+ is_windows = os.name == "nt"
+ if is_windows:
+ lib_filename = f"{extension_name}.dll"
+ container_extension_dir = (
+ "C:/Program Files/ApacheNiFiMiNiFi/nifi-minifi-cpp/extensions"
+ )
+ else:
+ lib_filename = f"lib{extension_name}.so"
+ container_extension_dir = "/opt/minifi/minifi-current/extensions/"
+
+ host_path = None
+ for path in possible_paths:
+ if os.path.exists(os.path.join(path, lib_filename)):
+ host_path = os.path.join(path, lib_filename)
+ break
+
+ assert host_path is not None, (
+ f"Could not find {lib_filename} in {[p for p in possible_paths]}"
+ )
+
+ with open(host_path, "rb") as f:
+ lib_content = f.read()
+
+ base_img = get_minifi_container_image()
+
+ if is_windows:
+ dockerfile = f"""
+FROM {base_img}
+COPY ["{lib_filename}", "{container_extension_dir}/{lib_filename}"]
+"""
+ else:
+ dockerfile = f"""
+FROM {base_img}
+COPY --chown=minificpp:minificpp {lib_filename} {container_extension_dir}
+RUN chmod 755 {container_extension_dir}{lib_filename}
+"""
+
+ builder = DockerImageBuilder(
+ image_tag=new_container_name,
+ dockerfile_content=dockerfile,
+ files_on_context={lib_filename: lib_content},
+ )
+
+ builder.build()
+ return new_container_name
+
+
+def before_all(context):
+ dir_path = os.path.dirname(os.path.realpath(__file__))
+ build_path = os.path.normpath(os.path.join(dir_path, "../../../target/release/"))
+ add_extension_to_minifi_container("minifi_pgp", [build_path], context)
+
+
+def before_scenario(context, scenario):
+ context.minifi_container_image = "apacheminificpp:minifi_pgp"
+ common_before_scenario(context, scenario)
+
+
+def after_scenario(context, scenario):
+ common_after_scenario(context, scenario)
diff --git a/minifi_rust/extensions/minifi_pgp/features/steps/steps.py b/minifi_rust/extensions/minifi_pgp/features/steps/steps.py
new file mode 100644
index 0000000000..b2a4d6e632
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/features/steps/steps.py
@@ -0,0 +1,100 @@
+import os
+from pathlib import Path
+
+import humanfriendly
+from behave import step, then
+
+from minifi_behave.steps import checking_steps # noqa: F401
+from minifi_behave.steps import configuration_steps # noqa: F401
+from minifi_behave.steps import core_steps # noqa: F401
+from minifi_behave.steps import flow_building_steps # noqa: F401
+from minifi_behave.core.helpers import wait_for_condition
+from minifi_behave.core.minifi_test_context import MinifiTestContext
+from minifi_behave.minifi.controller_service import ControllerService
+from minifi_behave.minifi.processor import Processor
+
+
+@step("an EncryptContentPGP processor with a PGPPublicKeyService is set up")
+def step_encrypt_content_with_service(context: MinifiTestContext):
+ dir_path = os.path.dirname(os.path.realpath(__file__))
+
+ public_key_service = ControllerService(
+ class_name="PGPPublicKeyService", service_name="my_public_keys"
+ )
+ alice_public_key = Path(f"{dir_path}/../../test_keys/keyring.asc").read_text()
+ public_key_service.add_property("Keyring", alice_public_key)
+ context.get_or_create_default_minifi_container().flow_definition.controller_services.append(
+ public_key_service
+ )
+
+ processor = Processor("EncryptContentPGP", "EncryptContentPGP")
+ processor.add_property("Public Key Service", "my_public_keys")
+ context.get_or_create_default_minifi_container().flow_definition.processors.append(
+ processor
+ )
+
+
+@step(
+ "a DecryptContentPGP processor named DecryptAlice with a PGPPrivateKeyService is set up for Alice"
+)
+def step_decrypt_content_for_alice(context: MinifiTestContext):
+ dir_path = os.path.dirname(os.path.realpath(__file__))
+
+ private_key_service = ControllerService(
+ class_name="PGPPrivateKeyService", service_name="alice_private_key"
+ )
+ alice_private_key = Path(
+ f"{dir_path}/../../test_keys/alice_private.asc"
+ ).read_text()
+ private_key_service.add_property("Key", alice_private_key)
+ private_key_service.add_property("Key Passphrase", "whiterabbit")
+ context.get_or_create_default_minifi_container().flow_definition.controller_services.append(
+ private_key_service
+ )
+
+ processor = Processor("DecryptContentPGP", "DecryptAlice")
+ processor.add_property("Private Key Service", "alice_private_key")
+ context.get_or_create_default_minifi_container().flow_definition.processors.append(
+ processor
+ )
+
+
+@step(
+ "a DecryptContentPGP processor named DecryptBob with a PGPPrivateKeyService is set up for Bob"
+)
+def step_decrypt_content_for_bob(context: MinifiTestContext):
+ dir_path = os.path.dirname(os.path.realpath(__file__))
+
+ private_key_service = ControllerService(
+ class_name="PGPPrivateKeyService", service_name="bob_private_key"
+ )
+ bob_private_key = Path(f"{dir_path}/../../test_keys/bob_private.asc").read_text()
+ private_key_service.add_property("Key", bob_private_key)
+ context.get_or_create_default_minifi_container().flow_definition.controller_services.append(
+ private_key_service
+ )
+
+ processor = Processor("DecryptContentPGP", "DecryptBob")
+ processor.add_property("Private Key Service", "bob_private_key")
+ context.get_or_create_default_minifi_container().flow_definition.processors.append(
+ processor
+ )
+
+
+@then(
+ 'an encrypted armored pgp file is placed in the "{directory}" directory in less than {duration}'
+)
+def then_armored_pgp_file_in_dir(
+ context: MinifiTestContext, directory: str, duration: str
+):
+ duration_seconds = humanfriendly.parse_timespan(duration)
+ assert wait_for_condition(
+ condition=lambda: (
+ context.get_or_create_default_minifi_container().directory_contains_file_with_regex(
+ directory, "-----BEGIN PGP MESSAGE-----"
+ )
+ ),
+ timeout_seconds=duration_seconds,
+ bail_condition=lambda: False,
+ context=context,
+ )
diff --git a/minifi_rust/extensions/minifi_pgp/minifi_pgp.md b/minifi_rust/extensions/minifi_pgp/minifi_pgp.md
new file mode 100644
index 0000000000..f2d9cbeab7
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/minifi_pgp.md
@@ -0,0 +1,120 @@
+
+
+## Table of Contents
+
+### Processors
+
+- [DecryptContentPGP](#DecryptContentPGP)
+- [EncryptContentPGP](#EncryptContentPGP)
+### Controller Services
+
+- [PGPPrivateKeyService](#PGPPrivateKeyService)
+- [PGPPublicKeyService](#PGPPublicKeyService)
+
+
+## DecryptContentPGP
+
+### Description
+
+Decrypt contents of OpenPGP messages. Using the Packaged Decryption Strategy preserves OpenPGP encoding to support subsequent signature verification.
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|---------------------|---------------|------------------------|-------------------------------------------------------------------------------------------------------------|
+| Decryption Strategy | DECRYPTED | DECRYPTED
PACKAGED | Strategy for writing files to success after decryption |
+| Symmetric Password | | | Password used for decrypting data encrypted with Password-Based Encryption
**Sensitive Property: true** |
+| Private Key Service | | | PGP Private Key Service for decrypting data encrypted with Public Key Encryption |
+
+### Relationships
+
+| Name | Description |
+|---------|----------------------|
+| success | Decryption Succeeded |
+| failure | Decryption Failed |
+
+### Output Attributes
+
+| Attribute | Relationship | Description |
+|---------------------------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| pgp.literal.data.filename | success | Filename from decrypted Literal Data (Note that OpenPGP signatures do not include the formatting octet, the file name, and the date field of the Literal Data packet in a signature hash; therefore, those fields are not protected against tampering in a signed document. Therefore a lot of implementation omit these inherently malleable metadata) |
+| pgp.literal.data.modified | success | Modified Date from decrypted Literal Data (Note that OpenPGP signatures do not include the formatting octet, the file name, and the date field of the Literal Data packet in a signature hash; therefore, those fields are not protected against tampering in a signed document. Therefore a lot of implementation omit these inherently malleable metadata) |
+
+
+## EncryptContentPGP
+
+### Description
+
+Encrypt contents using OpenPGP.
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|--------------------|---------------|------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| **File Encoding** | BINARY | ASCII
BINARY | File Encoding for encryption |
+| Symmetric Password | | | Password used for encrypting data with Password-Based Encryption
**Sensitive Property: true** |
+| Public Key Search | | | PGP Public Key Search will be used to match against the User ID or Key ID when formatted as uppercase hexadecimal string of 16 characters
**Supports Expression Language: true** |
+| Public Key Service | | | PGP Public Key Service for encrypting data with Public Key Encryption |
+
+### Relationships
+
+| Name | Description |
+|---------|----------------------|
+| success | Encryption Succeeded |
+| failure | Encryption Failed |
+
+### Output Attributes
+
+| Attribute | Relationship | Description |
+|-------------------|--------------|---------------|
+| pgp.file.encoding | success | File Encoding |
+
+
+## PGPPrivateKeyService
+
+### Description
+
+PGP Private Key Service provides Private Keys loaded from files or properties
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|----------------|---------------|------------------|---------------------------------------------------------------------------------------------------------|
+| Key File | | | File path to PGP Secret Key encoded in binary or ASCII Armor
**Supports Expression Language: true** |
+| Key | | | Secret Key encoded in ASCII Armor
**Sensitive Property: true** |
+| Key Passphrase | | | Passphrase used for decrypting Private Keys
**Sensitive Property: true** |
+
+
+## PGPPublicKeyService
+
+### Description
+
+PGP Public Key Service providing Public Keys loaded from files
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|--------------|---------------|------------------|--------------------------------------------------------------------------------------------------------------------|
+| Keyring File | | | File path to PGP Keyring or Secret Key encoded in binary or ASCII Armor
**Supports Expression Language: true** |
+| Keyring | | | PGP Keyring or Secret Key encoded in ASCII Armor
**Sensitive Property: true** |
diff --git a/minifi_rust/extensions/minifi_pgp/src/controller_services/key_file_property.rs b/minifi_rust/extensions/minifi_pgp/src/controller_services/key_file_property.rs
new file mode 100644
index 0000000000..4e0c876f01
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/controller_services/key_file_property.rs
@@ -0,0 +1,124 @@
+use minifi_native::{MinifiError, PropertyConstraints, PropertySchema, PropertyType};
+use pgp::composed::{Deserializable, SignedPublicKey, SignedSecretKey};
+
+pub(crate) struct SecretKeyFile {}
+
+impl PropertySchema for SecretKeyFile {
+ const CONSTRAINT: Option = None;
+ const IS_REQUIRED: bool = false;
+}
+
+impl PropertyType for SecretKeyFile {
+ type Output = Vec;
+
+ fn parse(s: &str) -> Result {
+ let mut result: Vec = Vec::new();
+ if let Ok((keys, _headers)) = SignedSecretKey::from_armor_file_many(s) {
+ result.extend(keys.filter_map(Result::ok));
+ } else if let Ok(keys) = SignedSecretKey::from_file_many(s) {
+ result.extend(keys.filter_map(Result::ok));
+ }
+ if result.is_empty() {
+ Err(MinifiError::validation(
+ "Couldnt load any valid secret keys",
+ ))
+ } else {
+ Ok(result)
+ }
+ }
+}
+
+pub(crate) struct PublicKeyFile {}
+impl PropertySchema for PublicKeyFile {
+ const CONSTRAINT: Option = None;
+ const IS_REQUIRED: bool = false;
+}
+
+impl PropertyType for PublicKeyFile {
+ type Output = Vec;
+
+ fn parse(s: &str) -> Result {
+ let mut result: Vec = Vec::new();
+ if let Ok((keys, _headers)) = SignedPublicKey::from_armor_file_many(s) {
+ result.extend(keys.filter_map(Result::ok));
+ } else if let Ok(keys) = SignedPublicKey::from_file_many(s) {
+ result.extend(keys.filter_map(Result::ok));
+ }
+ if result.is_empty() {
+ Err(MinifiError::validation(
+ "Couldnt load any valid public keys",
+ ))
+ } else {
+ Ok(result)
+ }
+ }
+}
+
+#[cfg(test)]
+mod secret_key_file_tests {
+ use super::*;
+ use crate::test_utils::get_test_key_path;
+
+ fn assert_invalid_secret_key_file(file_name: &str) {
+ assert!(SecretKeyFile::parse(&get_test_key_path(file_name)).is_err())
+ }
+ fn assert_valid_secret_key_file(file_name: &str) {
+ assert!(
+ !SecretKeyFile::parse(&get_test_key_path(file_name))
+ .unwrap()
+ .is_empty()
+ )
+ }
+ #[test]
+ fn test_invalid_secret_keyfiles() {
+ assert_invalid_secret_key_file("alice.asc");
+ assert_invalid_secret_key_file("alice.gpg");
+ assert_invalid_secret_key_file("garbage.gpg");
+ assert_invalid_secret_key_file("truncated_private.asc");
+ assert_invalid_secret_key_file("non_existent.asc");
+ }
+
+ #[test]
+ fn test_valid_secret_keyfiles() {
+ assert_valid_secret_key_file("alice_private.asc");
+ assert_valid_secret_key_file("alice_private.gpg");
+ assert_valid_secret_key_file("bob_private.asc");
+ assert_valid_secret_key_file("bob_private.gpg");
+ assert_valid_secret_key_file("secret_keyring.asc");
+ assert_valid_secret_key_file("secret_keyring.gpg");
+ }
+}
+
+#[cfg(test)]
+mod public_key_file_tests {
+ use crate::controller_services::key_file_property::PublicKeyFile;
+ use crate::test_utils::get_test_key_path;
+ use minifi_native::PropertyType;
+
+ fn assert_invalid_public_key_file(file_name: &str) {
+ assert!(PublicKeyFile::parse(&get_test_key_path(file_name)).is_err())
+ }
+ fn assert_valid_public_key_file(file_name: &str) {
+ assert!(
+ !PublicKeyFile::parse(&get_test_key_path(file_name))
+ .unwrap()
+ .is_empty()
+ )
+ }
+ #[test]
+ fn test_invalid_public_keyfiles() {
+ assert_invalid_public_key_file("alice_private.asc");
+ assert_invalid_public_key_file("alice_private.gpg");
+ assert_invalid_public_key_file("garbage.gpg");
+ assert_invalid_public_key_file("truncated.asc");
+ assert_invalid_public_key_file("non_existent.asc");
+ }
+
+ #[test]
+ fn test_valid_public_keyfiles() {
+ assert_valid_public_key_file("alice.asc");
+ assert_valid_public_key_file("alice.gpg");
+ assert_valid_public_key_file("keyring.asc");
+ assert_valid_public_key_file("keyring.gpg");
+ }
+}
diff --git a/minifi_rust/extensions/minifi_pgp/src/controller_services/key_lookup.rs b/minifi_rust/extensions/minifi_pgp/src/controller_services/key_lookup.rs
new file mode 100644
index 0000000000..cff723a718
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/controller_services/key_lookup.rs
@@ -0,0 +1,56 @@
+use pgp::composed::SignedKeyDetails;
+use pgp::types::KeyId;
+
+/// Returns true when `target_id` matches either:
+/// - the key's Key ID formatted as 16-character hex (case-insensitive), or
+/// - any of its User IDs as a case-insensitive substring match.
+pub(crate) fn key_matches(key_id: &KeyId, details: &SignedKeyDetails, target_id: &str) -> bool {
+ let target = target_id.trim();
+ if target.is_empty() {
+ return false;
+ }
+
+ if key_id.to_string().eq_ignore_ascii_case(target) {
+ return true;
+ }
+
+ let target_lower = target.to_ascii_lowercase();
+ details.users.iter().any(|user| {
+ user.id
+ .as_str()
+ .map(|user_id| user_id.to_ascii_lowercase().contains(&target_lower))
+ .unwrap_or(false)
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn key_id_from_hex(hex: &str) -> KeyId {
+ let mut bytes = [0u8; 8];
+ for (i, chunk) in hex.as_bytes().chunks(2).take(8).enumerate() {
+ bytes[i] = u8::from_str_radix(std::str::from_utf8(chunk).unwrap(), 16).unwrap();
+ }
+ KeyId::from(bytes)
+ }
+
+ #[test]
+ fn empty_target_never_matches() {
+ let details = SignedKeyDetails::new(vec![], vec![], vec![], vec![]);
+ let key_id = key_id_from_hex("1122334455667788");
+ assert!(!key_matches(&key_id, &details, ""));
+ assert!(!key_matches(&key_id, &details, " "));
+ }
+
+ #[test]
+ fn matches_key_id_case_insensitive() {
+ let details = SignedKeyDetails::new(vec![], vec![], vec![], vec![]);
+ let key_id = key_id_from_hex("11ABcdEF33445566");
+
+ assert!(key_matches(&key_id, &details, "11abcdef33445566"));
+ assert!(key_matches(&key_id, &details, "11ABCDEF33445566"));
+ assert!(!key_matches(&key_id, &details, "11abcdef3344556")); // 15 chars
+ assert!(!key_matches(&key_id, &details, "abcdef33445566"));
+ }
+}
diff --git a/minifi_rust/extensions/minifi_pgp/src/controller_services/key_property.rs b/minifi_rust/extensions/minifi_pgp/src/controller_services/key_property.rs
new file mode 100644
index 0000000000..b3d9aa07d0
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/controller_services/key_property.rs
@@ -0,0 +1,49 @@
+use minifi_native::{MinifiError, PropertyConstraints, PropertySchema, PropertyType};
+use pgp::composed::{Deserializable, SignedPublicKey, SignedSecretKey};
+
+pub(crate) struct SecretKey {}
+
+impl PropertySchema for SecretKey {
+ const CONSTRAINT: Option = None;
+ const IS_REQUIRED: bool = false;
+}
+
+impl PropertyType for SecretKey {
+ type Output = Vec;
+
+ fn parse(s: &str) -> Result {
+ let mut secret_keys: Vec = Vec::new();
+ if let Ok((keys, _headers)) = SignedSecretKey::from_armor_many(s.as_bytes()) {
+ secret_keys.extend(keys.filter_map(Result::ok));
+ }
+ if secret_keys.is_empty() {
+ return Err(MinifiError::validation(
+ "Couldnt load any valid secrey keys",
+ ));
+ }
+ Ok(secret_keys)
+ }
+}
+
+pub(crate) struct PublicKey {}
+impl PropertySchema for PublicKey {
+ const CONSTRAINT: Option = None;
+ const IS_REQUIRED: bool = false;
+}
+
+impl PropertyType for PublicKey {
+ type Output = Vec;
+
+ fn parse(s: &str) -> Result {
+ let mut public_keys: Vec = Vec::new();
+ if let Ok((keys, _headers)) = SignedPublicKey::from_armor_many(s.as_bytes()) {
+ public_keys.extend(keys.filter_map(Result::ok));
+ }
+ if public_keys.is_empty() {
+ return Err(MinifiError::validation(
+ "Couldnt load any valid public keys",
+ ));
+ }
+ Ok(public_keys)
+ }
+}
diff --git a/minifi_rust/extensions/minifi_pgp/src/controller_services/mod.rs b/minifi_rust/extensions/minifi_pgp/src/controller_services/mod.rs
new file mode 100644
index 0000000000..930207f53f
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/controller_services/mod.rs
@@ -0,0 +1,5 @@
+mod key_file_property;
+mod key_lookup;
+mod key_property;
+pub(crate) mod private_key_service;
+pub(crate) mod public_key_service;
diff --git a/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service.rs b/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service.rs
new file mode 100644
index 0000000000..848fa5603c
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service.rs
@@ -0,0 +1,203 @@
+mod controller_service_definition;
+use controller_service_definition::*;
+
+#[cfg(test)]
+use crate::controller_services::key_lookup::key_matches;
+use minifi_native::macros::ComponentIdentifier;
+use minifi_native::{EnableControllerService, GetProperty, Logger, MinifiError};
+use pgp::composed::{SignedSecretKey, TheRing};
+#[cfg(test)]
+use pgp::types::KeyDetails;
+
+#[derive(Debug, ComponentIdentifier)]
+pub(crate) struct PGPPrivateKeyService {
+ private_keys: Vec,
+ passphrase: pgp::types::Password,
+}
+
+impl EnableControllerService for PGPPrivateKeyService {
+ fn enable(context: &P, _logger: &L) -> Result
+ where
+ Self: Sized,
+ {
+ let mut private_keys = context.get_property(&KEY_FILE)?.unwrap_or_default();
+ private_keys.extend(context.get_property(&KEY)?.unwrap_or_default());
+
+ let passphrase = context.get_property(&KEY_PASSPHRASE)?.unwrap_or_default();
+
+ if private_keys.is_empty() {
+ return Err(MinifiError::validation("Could not load any valid keys"));
+ }
+ Ok(Self {
+ private_keys,
+ passphrase,
+ })
+ }
+}
+
+impl PGPPrivateKeyService {
+ pub fn get_the_ring(&'_ self) -> TheRing<'_> {
+ TheRing {
+ secret_keys: self.private_keys.iter().collect(),
+ key_passwords: vec![&self.passphrase],
+ message_password: vec![],
+ session_keys: vec![],
+ decrypt_options: Default::default(),
+ }
+ }
+
+ #[cfg(test)]
+ pub fn get_secret_key(&self, target_id: &str) -> Option<&SignedSecretKey> {
+ self.private_keys.iter().find(|private_key| {
+ key_matches(
+ &private_key.primary_key.legacy_key_id(),
+ &private_key.details,
+ target_id,
+ )
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::test_utils::get_test_key_path;
+ use minifi_native::{ComponentIdentifier, MockControllerServiceContext, MockLogger};
+
+ #[test]
+ fn test_component_id() {
+ assert_eq!(
+ PGPPrivateKeyService::CLASS_NAME,
+ "minifi_pgp::controller_services::private_key_service::PGPPrivateKeyService"
+ );
+ assert_eq!(PGPPrivateKeyService::GROUP_NAME, "minifi_pgp");
+ assert_eq!(PGPPrivateKeyService::VERSION, "1.0.0");
+ }
+
+ #[test]
+ fn default_fails() {
+ let context = MockControllerServiceContext::new();
+ assert!(PGPPrivateKeyService::enable(&context, &MockLogger::new()).is_err());
+ }
+
+ #[test]
+ fn single_armored_key_file() {
+ let mut context = MockControllerServiceContext::new();
+ context.properties.insert(
+ "Key File".to_string(),
+ get_test_key_path("alice_private.asc"),
+ );
+
+ let service =
+ PGPPrivateKeyService::enable(&context, &MockLogger::new()).expect("should enable");
+ assert!(service.get_secret_key("Alice").is_some());
+ assert!(service.get_secret_key("alice@example.com").is_some());
+
+ assert!(service.get_secret_key("Bob").is_none());
+ assert!(service.get_secret_key("Carol").is_none());
+ }
+
+ #[test]
+ fn single_binary_key_file() {
+ let mut context = MockControllerServiceContext::new();
+ context.properties.insert(
+ "Key File".to_string(),
+ get_test_key_path("alice_private.gpg"),
+ );
+
+ let service =
+ PGPPrivateKeyService::enable(&context, &MockLogger::new()).expect("should enable");
+ assert!(service.get_secret_key("A").is_some());
+ assert!(service.get_secret_key("Alice").is_some());
+ assert!(
+ service
+ .get_secret_key("Alice ")
+ .is_some()
+ );
+
+ assert!(service.get_secret_key("").is_none());
+
+ assert!(service.get_secret_key("Bob").is_none());
+ assert!(service.get_secret_key("Carol").is_none());
+ }
+
+ #[test]
+ fn armored_keyring_key_file() {
+ let mut context = MockControllerServiceContext::new();
+ context.properties.insert(
+ "Key File".to_string(),
+ get_test_key_path("secret_keyring.asc"),
+ );
+
+ let service =
+ PGPPrivateKeyService::enable(&context, &MockLogger::new()).expect("should enable");
+ assert!(service.get_secret_key("Alice").is_some());
+ assert!(service.get_secret_key("Bob").is_some());
+ assert!(service.get_secret_key("bob@home.io").is_some());
+ assert!(service.get_secret_key("bob@work.com").is_some());
+ assert!(service.get_secret_key("Carol").is_none());
+ }
+
+ #[test]
+ fn binary_keyring_key_file() {
+ let mut context = MockControllerServiceContext::new();
+ context.properties.insert(
+ "Key File".to_string(),
+ get_test_key_path("secret_keyring.gpg"),
+ );
+
+ let service =
+ PGPPrivateKeyService::enable(&context, &MockLogger::new()).expect("should enable");
+ assert!(service.get_secret_key("Alice").is_some());
+ assert!(service.get_secret_key("Bob").is_some());
+ assert!(service.get_secret_key("bob@home.io").is_some());
+ assert!(service.get_secret_key("bob@work.com").is_some());
+ assert!(service.get_secret_key("Carol").is_none());
+ }
+
+ #[test]
+ fn armored_keyring() {
+ let mut context = MockControllerServiceContext::new();
+
+ let file_content = std::fs::read_to_string(get_test_key_path("secret_keyring.asc"))
+ .expect("required for test");
+
+ context.properties.insert("Key".to_string(), file_content);
+
+ let service =
+ PGPPrivateKeyService::enable(&context, &MockLogger::new()).expect("should enable");
+ assert!(service.get_secret_key("Alice").is_some());
+ assert!(service.get_secret_key("Bob").is_some());
+ assert!(service.get_secret_key("bob@home.io").is_some());
+ assert!(service.get_secret_key("bob@work.com").is_some());
+ assert!(service.get_secret_key("Carol").is_none());
+ }
+
+ #[test]
+ fn armored_single_key() {
+ let mut context = MockControllerServiceContext::new();
+
+ let file_content = std::fs::read_to_string(get_test_key_path("alice_private.asc"))
+ .expect("required for test");
+
+ context.properties.insert("Key".to_string(), file_content);
+
+ let service =
+ PGPPrivateKeyService::enable(&context, &MockLogger::new()).expect("should enable");
+ assert!(service.get_secret_key("Alice").is_some());
+ assert!(service.get_secret_key("Bob").is_none());
+ assert!(service.get_secret_key("Carol").is_none());
+ }
+
+ #[test]
+ fn public_ascii_key() {
+ let mut context = MockControllerServiceContext::new();
+
+ let file_content =
+ std::fs::read_to_string(get_test_key_path("alice.asc")).expect("required for test");
+
+ context.properties.insert("Key".to_string(), file_content);
+
+ assert!(PGPPrivateKeyService::enable(&context, &MockLogger::new()).is_err());
+ }
+}
diff --git a/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service/controller_service_definition.rs b/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service/controller_service_definition.rs
new file mode 100644
index 0000000000..fe421553ad
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service/controller_service_definition.rs
@@ -0,0 +1,31 @@
+use super::PGPPrivateKeyService;
+use crate::controller_services::key_file_property::SecretKeyFile;
+use crate::controller_services::key_property::SecretKey;
+use crate::utils;
+use minifi_native::{
+ ControllerServiceDefinition, Property, PropertyDefinition, ProvidedInterface,
+ property_definitions,
+};
+
+pub(super) const KEY_FILE: Property