diff --git a/pom.xml b/pom.xml
index c606060..d37779e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -79,8 +79,18 @@
2014
5.21.0
6.0.3
+ 1.83
2.0.17
+
+
+
+ org.bouncycastle
+ bcpkix-jdk18on
+ ${org.bouncycastle.version}
+
+
+
@@ -336,6 +346,11 @@
provided
+
+ org.bouncycastle
+ bcpkix-jdk18on
+ test
+
org.junit.jupiter
junit-jupiter
diff --git a/src/main/java/org/apache/nifi/NarMojo.java b/src/main/java/org/apache/nifi/NarMojo.java
index 56f059d..eca5f97 100644
--- a/src/main/java/org/apache/nifi/NarMojo.java
+++ b/src/main/java/org/apache/nifi/NarMojo.java
@@ -86,9 +86,14 @@
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
+import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.security.KeyStore;
+import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
@@ -105,6 +110,8 @@
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.stream.Collectors;
+import java.util.zip.ZipFile;
+import jdk.security.jarsigner.JarSigner;
/**
* Packages the current project as an Apache NiFi Archive (NAR).
@@ -477,6 +484,51 @@ public class NarMojo extends AbstractMojo {
@Parameter(property = "skipDocGeneration", defaultValue = "false")
protected boolean skipDocGeneration;
+ /**
+ * Whether to sign the produced NAR file. Requires signKeystore, signStorepass, and signAlias.
+ */
+ @Parameter(property = "nar.sign", defaultValue = "false")
+ protected boolean sign;
+
+ /**
+ * Path to the keystore file containing the signing key.
+ */
+ @Parameter(property = "nar.sign.keystore")
+ protected String signKeystore;
+
+ /**
+ * Password for the keystore. Supports Maven password encryption via settings-security.xml.
+ */
+ @Parameter(property = "nar.sign.storepass")
+ protected String signStorepass;
+
+ /**
+ * Alias of the key entry in the keystore to use for signing.
+ */
+ @Parameter(property = "nar.sign.alias")
+ protected String signAlias;
+
+ /**
+ * Password for the key entry. Defaults to the keystore password if not specified.
+ */
+ @Parameter(property = "nar.sign.keypass")
+ protected String signKeypass;
+
+ /**
+ * Keystore type. Defaults to PKCS12.
+ */
+ @Parameter(property = "nar.sign.storetype", defaultValue = "PKCS12")
+ protected String signStoretype;
+
+ /**
+ * URL of a Time Stamping Authority (TSA) for timestamping the signature.
+ * When set, the signature includes a trusted timestamp so that it remains
+ * valid even after the signing certificate expires.
+ * Example: http://timestamp.digicert.com
+ */
+ @Parameter(property = "nar.sign.tsa")
+ protected String signTsa;
+
/**
* The {@link RepositorySystemSession} used for obtaining the local and remote artifact repositories.
*/
@@ -1176,9 +1228,21 @@ private File getDependenciesDirectory() {
}
private void makeNar() throws MojoExecutionException {
+ KeyStore.PrivateKeyEntry privateKeyEntry = null;
+
+ if (sign) {
+ privateKeyEntry = loadSigningKey();
+ final X509Certificate signerCert = (X509Certificate) privateKeyEntry.getCertificate();
+ archive.addManifestEntry("Nar-Signed-By", signerCert.getSubjectX500Principal().getName());
+ }
+
final NarResult narResult = createArchive();
final File narFile = narResult.getNarFile();
+ if (sign) {
+ signNar(narFile, privateKeyEntry);
+ }
+
if (classifier != null) {
projectHelper.attachArtifact(project, "nar", classifier, narFile);
} else {
@@ -1191,6 +1255,65 @@ private void makeNar() throws MojoExecutionException {
}
}
+ KeyStore.PrivateKeyEntry loadSigningKey() throws MojoExecutionException {
+ if (!notEmpty(signKeystore)) {
+ throw new MojoExecutionException("NAR signing is enabled but nar.sign.keystore is not configured");
+ }
+ if (!notEmpty(signStorepass)) {
+ throw new MojoExecutionException("NAR signing is enabled but nar.sign.storepass is not configured");
+ }
+ if (!notEmpty(signAlias)) {
+ throw new MojoExecutionException("NAR signing is enabled but nar.sign.alias is not configured");
+ }
+
+ try {
+ final KeyStore keyStore = KeyStore.getInstance(signStoretype);
+ try (final InputStream keystoreStream = Files.newInputStream(Path.of(signKeystore))) {
+ keyStore.load(keystoreStream, signStorepass.toCharArray());
+ }
+
+ final char[] keyPassword = notEmpty(signKeypass) ? signKeypass.toCharArray() : signStorepass.toCharArray();
+ final KeyStore.PrivateKeyEntry privateKeyEntry = (KeyStore.PrivateKeyEntry) keyStore.getEntry(
+ signAlias, new KeyStore.PasswordProtection(keyPassword));
+
+ if (privateKeyEntry == null) {
+ throw new MojoExecutionException(
+ "No private key entry found in keystore [%s] with alias [%s]".formatted(signKeystore, signAlias));
+ }
+
+ return privateKeyEntry;
+ } catch (final MojoExecutionException e) {
+ throw e;
+ } catch (final Exception e) {
+ throw new MojoExecutionException("Failed to load signing key from keystore [%s]".formatted(signKeystore), e);
+ }
+ }
+
+ void signNar(final File narFile, final KeyStore.PrivateKeyEntry privateKeyEntry) throws MojoExecutionException {
+ getLog().info("Signing NAR: " + narFile.getName());
+
+ try {
+ final JarSigner.Builder builder = new JarSigner.Builder(privateKeyEntry).digestAlgorithm("SHA-256");
+
+ if (notEmpty(signTsa)) {
+ builder.tsa(URI.create(signTsa));
+ }
+
+ final JarSigner signer = builder.build();
+
+ final File signedFile = new File(narFile.getParentFile(), narFile.getName() + ".signed");
+ try (final ZipFile zipFile = new ZipFile(narFile);
+ final OutputStream outputStream = new FileOutputStream(signedFile)) {
+ signer.sign(zipFile, outputStream);
+ }
+
+ Files.move(signedFile.toPath(), narFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ getLog().info("Signed NAR [%s] with alias [%s] from keystore [%s]".formatted(narFile.getName(), signAlias, signKeystore));
+ } catch (final Exception e) {
+ throw new MojoExecutionException("Failed to sign NAR: " + narFile.getName(), e);
+ }
+ }
+
private NarResult createArchive() throws MojoExecutionException {
final File outputDirectory = projectBuildDirectory;
File narFile = getNarFile(outputDirectory, finalName, classifier);
diff --git a/src/test/java/org/apache/nifi/NarMojoTest.java b/src/test/java/org/apache/nifi/NarMojoTest.java
new file mode 100644
index 0000000..fa321cb
--- /dev/null
+++ b/src/test/java/org/apache/nifi/NarMojoTest.java
@@ -0,0 +1,289 @@
+/*
+ * 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;
+
+import org.apache.maven.plugin.MojoExecutionException;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.math.BigInteger;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.CodeSigner;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.security.spec.ECGenParameterSpec;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
+
+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 NarMojoTest {
+
+ private static final String TEST_PASSWORD = "testpass";
+ private static final String TEST_ALIAS = "test-signer";
+
+ @TempDir
+ static Path tempDir;
+
+ private static Path testKeystorePath;
+
+ @BeforeAll
+ static void createTestKeystore() throws Exception {
+ testKeystorePath = tempDir.resolve("test-keystore.p12");
+
+ final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC");
+ keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1"));
+ final KeyPair keyPair = keyPairGenerator.generateKeyPair();
+
+ final X500Name subject = new X500Name("CN=Test NAR Signer, O=Apache NiFi Test, C=US");
+ final Instant now = Instant.now();
+ final ContentSigner contentSigner = new JcaContentSignerBuilder("SHA256withECDSA").build(keyPair.getPrivate());
+ final X509CertificateHolder certificateHolder = new JcaX509v3CertificateBuilder(
+ subject,
+ BigInteger.ONE,
+ Date.from(now),
+ Date.from(now.plus(365, ChronoUnit.DAYS)),
+ subject,
+ keyPair.getPublic()
+ ).build(contentSigner);
+
+ final X509Certificate certificate = new JcaX509CertificateConverter().getCertificate(certificateHolder);
+
+ final KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ keyStore.load(null, null);
+ keyStore.setKeyEntry(TEST_ALIAS, keyPair.getPrivate(), TEST_PASSWORD.toCharArray(), new Certificate[]{certificate});
+
+ try (final OutputStream out = Files.newOutputStream(testKeystorePath)) {
+ keyStore.store(out, TEST_PASSWORD.toCharArray());
+ }
+ }
+
+ @Test
+ void testSignNar() throws Exception {
+ final File narFile = createTestNar("test-sign");
+
+ final NarMojo mojo = createSigningMojo();
+ final KeyStore.PrivateKeyEntry keyEntry = mojo.loadSigningKey();
+ mojo.signNar(narFile, keyEntry);
+
+ try (final JarFile jarFile = new JarFile(narFile, true)) {
+ final List signableEntries = readAllEntriesAndCollectSignable(jarFile);
+ assertFalse(signableEntries.isEmpty(), "NAR must contain signable entries");
+
+ for (final JarEntry entry : signableEntries) {
+ final CodeSigner[] signers = entry.getCodeSigners();
+ assertNotNull(signers, "Entry [%s] must have code signers".formatted(entry.getName()));
+ assertTrue(signers.length > 0, "Entry [%s] must have at least one code signer".formatted(entry.getName()));
+ }
+ }
+ }
+
+ @Test
+ void testLoadSigningKeyReturnsCertificateWithExpectedDn() throws Exception {
+ final NarMojo mojo = createSigningMojo();
+ final KeyStore.PrivateKeyEntry keyEntry = mojo.loadSigningKey();
+
+ assertNotNull(keyEntry);
+ final X509Certificate cert = (X509Certificate) keyEntry.getCertificate();
+ final String dn = cert.getSubjectX500Principal().getName();
+ assertTrue(dn.contains("CN=Test NAR Signer"), "Signer DN must contain the expected CN, got: " + dn);
+ }
+
+ @Test
+ void testSignNarDisabledByDefault() throws Exception {
+ final File narFile = createTestNar("test-unsigned");
+
+ try (final JarFile jarFile = new JarFile(narFile, true)) {
+ final List signableEntries = readAllEntriesAndCollectSignable(jarFile);
+ for (final JarEntry entry : signableEntries) {
+ final CodeSigner[] signers = entry.getCodeSigners();
+ assertTrue(signers == null || signers.length == 0,
+ "Unsigned NAR entry [%s] must not have code signers".formatted(entry.getName()));
+ }
+ }
+ }
+
+ @Test
+ void testSignNarMissingKeystore() {
+ final NarMojo mojo = new NarMojo();
+ mojo.signKeystore = null;
+ mojo.signStorepass = TEST_PASSWORD;
+ mojo.signAlias = TEST_ALIAS;
+ mojo.signStoretype = "PKCS12";
+
+ final MojoExecutionException exception = assertThrows(MojoExecutionException.class, mojo::loadSigningKey);
+ assertTrue(exception.getMessage().contains("nar.sign.keystore"));
+ }
+
+ @Test
+ void testSignNarMissingStorepass() {
+ final NarMojo mojo = new NarMojo();
+ mojo.signKeystore = testKeystorePath.toString();
+ mojo.signStorepass = null;
+ mojo.signAlias = TEST_ALIAS;
+ mojo.signStoretype = "PKCS12";
+
+ final MojoExecutionException exception = assertThrows(MojoExecutionException.class, mojo::loadSigningKey);
+ assertTrue(exception.getMessage().contains("nar.sign.storepass"));
+ }
+
+ @Test
+ void testSignNarMissingAlias() {
+ final NarMojo mojo = new NarMojo();
+ mojo.signKeystore = testKeystorePath.toString();
+ mojo.signStorepass = TEST_PASSWORD;
+ mojo.signAlias = null;
+ mojo.signStoretype = "PKCS12";
+
+ final MojoExecutionException exception = assertThrows(MojoExecutionException.class, mojo::loadSigningKey);
+ assertTrue(exception.getMessage().contains("nar.sign.alias"));
+ }
+
+ @Test
+ void testSignNarInvalidAlias() {
+ final NarMojo mojo = createSigningMojo();
+ mojo.signAlias = "nonexistent-alias";
+
+ final MojoExecutionException exception = assertThrows(MojoExecutionException.class, mojo::loadSigningKey);
+ assertNotNull(exception.getMessage());
+ }
+
+ @Test
+ void testSignNarPreservesContent() throws Exception {
+ final File narFile = createTestNar("test-preserve");
+
+ final Set originalEntries = new HashSet<>();
+ try (final JarFile jarFile = new JarFile(narFile)) {
+ final Enumeration entries = jarFile.entries();
+ while (entries.hasMoreElements()) {
+ originalEntries.add(entries.nextElement().getName());
+ }
+ }
+
+ final NarMojo mojo = createSigningMojo();
+ final KeyStore.PrivateKeyEntry keyEntry = mojo.loadSigningKey();
+ mojo.signNar(narFile, keyEntry);
+
+ try (final JarFile jarFile = new JarFile(narFile, true)) {
+ final Enumeration entries = jarFile.entries();
+ final Set signedEntries = new HashSet<>();
+ while (entries.hasMoreElements()) {
+ final JarEntry entry = entries.nextElement();
+ signedEntries.add(entry.getName());
+ try (final InputStream is = jarFile.getInputStream(entry)) {
+ assertNotNull(is.readAllBytes(), "Entry [%s] must be readable after signing".formatted(entry.getName()));
+ }
+ }
+
+ for (final String original : originalEntries) {
+ assertTrue(signedEntries.contains(original),
+ "Original entry [%s] must still be present after signing".formatted(original));
+ }
+ }
+ }
+
+ private NarMojo createSigningMojo() {
+ final NarMojo mojo = new NarMojo();
+ mojo.sign = true;
+ mojo.signKeystore = testKeystorePath.toString();
+ mojo.signStorepass = TEST_PASSWORD;
+ mojo.signAlias = TEST_ALIAS;
+ mojo.signStoretype = "PKCS12";
+ return mojo;
+ }
+
+ private File createTestNar(final String name) throws Exception {
+ final Path narPath = tempDir.resolve(name + ".nar");
+ final Manifest manifest = new Manifest();
+ manifest.getMainAttributes().putValue("Manifest-Version", "1.0");
+ manifest.getMainAttributes().putValue("Nar-Id", name);
+ manifest.getMainAttributes().putValue("Nar-Group", "org.apache.nifi.test");
+ manifest.getMainAttributes().putValue("Nar-Version", "1.0.0");
+
+ try (final JarOutputStream jos = new JarOutputStream(new FileOutputStream(narPath.toFile()), manifest)) {
+ jos.putNextEntry(new JarEntry("META-INF/bundled-dependencies/"));
+ jos.closeEntry();
+
+ jos.putNextEntry(new JarEntry("META-INF/bundled-dependencies/test-lib.jar"));
+ jos.write("fake jar content for testing".getBytes());
+ jos.closeEntry();
+
+ jos.putNextEntry(new JarEntry("META-INF/docs/extension-manifest.xml"));
+ jos.write("".getBytes());
+ jos.closeEntry();
+ }
+
+ return narPath.toFile();
+ }
+
+ private List readAllEntriesAndCollectSignable(final JarFile jarFile) throws Exception {
+ final Enumeration entries = jarFile.entries();
+ final List signableEntries = new ArrayList<>();
+
+ while (entries.hasMoreElements()) {
+ final JarEntry entry = entries.nextElement();
+ try (final InputStream is = jarFile.getInputStream(entry)) {
+ is.readAllBytes();
+ }
+ if (!entry.isDirectory() && !isSignatureRelatedEntry(entry.getName())) {
+ signableEntries.add(entry);
+ }
+ }
+
+ return signableEntries;
+ }
+
+ private boolean isSignatureRelatedEntry(final String name) {
+ if (name.equals("META-INF/MANIFEST.MF")) {
+ return true;
+ }
+ if (!name.startsWith("META-INF/")) {
+ return false;
+ }
+ final String upperName = name.toUpperCase();
+ return (upperName.endsWith(".SF") || upperName.endsWith(".RSA") || upperName.endsWith(".EC") || upperName.endsWith(".DSA"))
+ && name.indexOf('/', "META-INF/".length()) == -1;
+ }
+}