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; + } +}