diff --git a/pgp-keys-map.list b/pgp-keys-map.list
index f0a0edb..c507a97 100644
--- a/pgp-keys-map.list
+++ b/pgp-keys-map.list
@@ -15,8 +15,13 @@
# specific language governing permissions and limitations
# under the License.
+com.kohlschutter.junixsocket:junixsocket-common = 0xB5C082F1158B8C92AE3E5E1C29B8FEA02804261C
+com.kohlschutter.junixsocket:junixsocket-core = 0xB5C082F1158B8C92AE3E5E1C29B8FEA02804261C
+com.kohlschutter.junixsocket:junixsocket-native-common = 0xB5C082F1158B8C92AE3E5E1C29B8FEA02804261C
commons-io:commons-io = 0x2DB4F1EF0FA761ECC4EA935C86FDC7E2A11262CB
org.apiguardian:apiguardian-api = 0xFF6E2C001948C5F2F38B0CC385911F425EC61B51
+org.bouncycastle:bcpg-jdk18on = 0x7B121B76A7ED6CE6E60AD51784E913A8E3A748C0
+org.bouncycastle:bcprov-jdk18on = 0x7B121B76A7ED6CE6E60AD51784E913A8E3A748C0
org.junit.jupiter:junit-jupiter-api = 0xFF6E2C001948C5F2F38B0CC385911F425EC61B51
org.junit.jupiter:junit-jupiter-params = 0xFF6E2C001948C5F2F38B0CC385911F425EC61B51
org.junit.platform:junit-platform-commons = 0xFF6E2C001948C5F2F38B0CC385911F425EC61B51
diff --git a/pom.xml b/pom.xml
index a0e437d..c547723 100644
--- a/pom.xml
+++ b/pom.xml
@@ -60,9 +60,10 @@ under the License.
+ 83.9.61.9.18
- 8
+ 1.772023-05-03T01:33:44Z@
@@ -120,6 +121,22 @@ under the License.
plexus-utils3.5.1
+
+ org.bouncycastle
+ bcpg-jdk18on
+ ${bouncycastleVersion}
+
+
+ org.bouncycastle
+ bcprov-jdk18on
+ ${bouncycastleVersion}
+
+
+ com.kohlschutter.junixsocket
+ junixsocket-core
+ 2.9.0
+ pom
+ org.junit.jupiter
@@ -179,8 +196,8 @@ under the License.
apache-rat-plugin
-
- src/test/resources/gnupg/**
+
+ src/test/resources/**
@@ -220,6 +237,67 @@ under the License.
+ org.apache.maven.plugins
+ maven-invoker-plugin
+
+ src/it/settings.xml
+
+ *
+
+
+
+ alternative-secret-keyring
+
+
+ clean
+ install
+
+
+ ${project.build.testOutputDirectory}/gnupg
+
+
+
+
+ integration-test
+ none
+
+
+ integration-test-install
+
+ install
+
+ integration-test
+
+
+ gpg-integration-tests
+
+ run
+
+ integration-test
+
+
+ gpg
+
+
+
+
+ bc-integration-tests
+
+ run
+
+ integration-test
+
+
+ bc
+
+ ${project.basedir}/src/test/resources/signing-key.asc
+
+
+
+
+
+
+
org.apache.maven.pluginsmaven-failsafe-plugin
@@ -243,27 +321,6 @@ under the License.
-
- org.apache.maven.plugins
- maven-invoker-plugin
-
- src/it/settings.xml
-
- *
-
-
-
- alternative-secret-keyring
-
-
- clean
- install
-
-
- ${project.build.testOutputDirectory}/gnupg
-
-
-
diff --git a/src/it/sign-release-without-passphrase/verify.groovy b/src/it/sign-release-without-passphrase/verify.groovy
index eaeee90..5815a83 100644
--- a/src/it/sign-release-without-passphrase/verify.groovy
+++ b/src/it/sign-release-without-passphrase/verify.groovy
@@ -28,8 +28,8 @@ if (!logContent.contains("Total time: ") || !logContent.contains("Finished at: "
throw new Exception("Maven build did not fail, but timed out")
}
-// assert that the Maven build failed, because pinentry is not allowed in non-interactive mode
-if (!logContent.contains("[GNUPG:] FAILURE sign 67108949")) {
+// gpg: assert that the Maven build failed, because pinentry is not allowed in non-interactive mode
+// bc: assert that the Maven build failed, because key to sign is encrypted by no passphrase provided
+if (!logContent.contains("[GNUPG:] FAILURE sign 67108949") && !logContent.contains("Secret key is encrypted but no passphrase provided")) {
throw new Exception("Maven build did not fail in consequence of pinentry not being available to GPG")
}
-
diff --git a/src/main/java/org/apache/maven/plugins/gpg/AbstractGpgMojo.java b/src/main/java/org/apache/maven/plugins/gpg/AbstractGpgMojo.java
index e0bb093..691834d 100644
--- a/src/main/java/org/apache/maven/plugins/gpg/AbstractGpgMojo.java
+++ b/src/main/java/org/apache/maven/plugins/gpg/AbstractGpgMojo.java
@@ -32,10 +32,61 @@
* @author Benjamin Bentmann
*/
public abstract class AbstractGpgMojo extends AbstractMojo {
+ public static final String DEFAULT_ENV_MAVEN_GPG_KEY = "MAVEN_GPG_KEY";
+ public static final String DEFAULT_ENV_MAVEN_GPG_FINGERPRINT = "MAVEN_GPG_KEY_FINGERPRINT";
public static final String DEFAULT_ENV_MAVEN_GPG_PASSPHRASE = "MAVEN_GPG_PASSPHRASE";
+ /**
+ * BC Signer only: The comma separate list of Unix Domain Socket paths, to use to communicate with GnuPG agent.
+ * If relative, they are resolved against user home directory.
+ *
+ * @since 3.2.0
+ */
+ @Parameter(property = "gpg.agentSocketLocations", defaultValue = ".gnupg/S.gpg-agent")
+ private String agentSocketLocations;
+
+ /**
+ * BC Signer only: The path of the exported key in TSK format, and probably passphrase protected. If relative,
+ * the file is resolved against Maven local repository root.
+ *
+ * Note: it is not recommended to have sensitive files on disk or SCM repository, this mode is more to be used
+ * in local environment (workstations) or for testing purposes.
+ *
+ * @since 3.2.0
+ */
+ @Parameter(property = "gpg.keyFilePath", defaultValue = "maven-signing-key.key")
+ private String keyFilePath;
+
+ /**
+ * BC Signer only: The fingerprint of the key to use for signing. If not given, first key in keyring will be used.
+ *
+ * @since 3.2.0
+ */
+ @Parameter(property = "gpg.keyFingerprint")
+ private String keyFingerprint;
+
+ /**
+ * BC Signer only: The env variable name where the GnuPG key is set. The default value is {@code MAVEN_GPG_KEY}.
+ * To use BC Signer you must provide GnuPG key, as it does not use GnuPG home directory to extract/find the
+ * key (while it does use GnuPG Agent to ask for password in interactive mode).
+ *
+ * @since 3.2.0
+ */
+ @Parameter(property = "gpg.keyEnvName", defaultValue = DEFAULT_ENV_MAVEN_GPG_KEY)
+ private String keyEnvName;
+
+ /**
+ * BC Signer only: The env variable name where the GnuPG key fingerprint is set, if the provided keyring contains
+ * multiple keys. The default value is {@code MAVEN_GPG_KEY_FINGERPRINT}.
+ *
+ * @since 3.2.0
+ */
+ @Parameter(property = "gpg.keyFingerprintEnvName", defaultValue = DEFAULT_ENV_MAVEN_GPG_FINGERPRINT)
+ private String keyFingerprintEnvName;
+
/**
* The env variable name where the GnuPG passphrase is set. The default value is {@code MAVEN_GPG_PASSPHRASE}.
+ * This is the recommended way to pass passphrase for signing in batch mode execution of Maven.
*
* @since 3.2.0
*/
@@ -43,7 +94,7 @@ public abstract class AbstractGpgMojo extends AbstractMojo {
private String passphraseEnvName;
/**
- * The directory from which gpg will load keyrings. If not specified, gpg will use the value configured for its
+ * GPG Signer only: The directory from which gpg will load keyrings. If not specified, gpg will use the value configured for its
* installation, e.g. ~/.gnupg or %APPDATA%/gnupg.
*
* @since 1.0
@@ -53,7 +104,9 @@ public abstract class AbstractGpgMojo extends AbstractMojo {
/**
* The passphrase to use when signing. If not given, look up the value under Maven
- * settings using server id at 'passphraseServerKey' configuration.
+ * settings using server id at 'passphraseServerKey' configuration. Do not use this parameter, if set, the
+ * plugin will fail. Passphrase should be provided only via gpg-agent (interactive) or via env variable
+ * (non-interactive).
*
* @deprecated Do not use this configuration, plugin will fail if set.
**/
@@ -62,9 +115,11 @@ public abstract class AbstractGpgMojo extends AbstractMojo {
private String passphrase;
/**
- * Server id to lookup the passphrase under Maven settings.
- * @since 1.6
+ * Server id to lookup the passphrase under Maven settings. Do not use this parameter, if set, the
+ * plugin will fail. Passphrase should be provided only via gpg-agent (interactive) or via env variable
+ * (non-interactive).
*
+ * @since 1.6
* @deprecated Do not use this configuration, plugin will fail if set.
**/
@Deprecated
@@ -72,27 +127,32 @@ public abstract class AbstractGpgMojo extends AbstractMojo {
private String passphraseServerId;
/**
- * The "name" of the key to sign with. Passed to gpg as --local-user.
+ * GPG Signer only: The "name" of the key to sign with. Passed to gpg as --local-user.
*/
@Parameter(property = "gpg.keyname")
private String keyname;
/**
- * Passes --use-agent or --no-use-agent to gpg. If using an agent, the passphrase is
- * optional as the agent will provide it. For gpg2, specify true as --no-use-agent was removed in gpg2 and doesn't
- * ask for a passphrase anymore.
+ * GPG Signer only: Passes --use-agent or --no-use-agent to gpg. If using an agent, the
+ * passphrase is optional as the agent will provide it. For gpg2, specify true as --no-use-agent was removed in
+ * gpg2 and doesn't ask for a passphrase anymore. Deprecated, and better to rely on session "interactive" setting
+ * (if interactive, agent will be used, otherwise not).
+ *
+ * @deprecated
*/
+ @Deprecated
@Parameter(property = "gpg.useagent", defaultValue = "true")
private boolean useAgent;
/**
+ * Detect is session interactive or not.
*/
@Parameter(defaultValue = "${settings.interactiveMode}", readonly = true)
private boolean interactive;
/**
- * The path to the GnuPG executable to use for artifact signing. Defaults to either "gpg" or "gpg.exe" depending on
- * the operating system.
+ * GPG Signer only: The path to the GnuPG executable to use for artifact signing. Defaults to either "gpg" or
+ * "gpg.exe" depending on the operating system.
*
* @since 1.1
*/
@@ -100,7 +160,7 @@ public abstract class AbstractGpgMojo extends AbstractMojo {
private String executable;
/**
- * Whether to add the default keyrings from gpg's home directory to the list of used keyrings.
+ * GPG Signer only: Whether to add the default keyrings from gpg's home directory to the list of used keyrings.
*
* @since 1.2
*/
@@ -108,32 +168,42 @@ public abstract class AbstractGpgMojo extends AbstractMojo {
private boolean defaultKeyring;
/**
- *
The path to a secret keyring to add to the list of keyrings. By default, only the {@code secring.gpg} from
- * gpg's home directory is considered. Use this option (in combination with {@link #publicKeyring} and
- * {@link #defaultKeyring} if required) to use a different secret key. Note: Relative paths are resolved
- * against gpg's home directory, not the project base directory.
+ * GPG Signer only: The path to a secret keyring to add to the list of keyrings. By default, only the
+ * {@code secring.gpg} from gpg's home directory is considered. Use this option (in combination with
+ * {@link #publicKeyring} and {@link #defaultKeyring} if required) to use a different secret key.
+ * Note: Relative paths are resolved against gpg's home directory, not the project base directory.
+ *
* NOTE: As of gpg 2.1 this is an obsolete option and ignored. All secret keys are stored in the
* ‘private-keys-v1.d’ directory below the GnuPG home directory.
*
* @since 1.2
+ * @deprecated
*/
+ @Deprecated
@Parameter(property = "gpg.secretKeyring")
private String secretKeyring;
/**
- * The path to a public keyring to add to the list of keyrings. By default, only the {@code pubring.gpg} from gpg's
- * home directory is considered. Use this option (and {@link #defaultKeyring} if required) to use a different public
- * key. Note: Relative paths are resolved against gpg's home directory, not the project base directory.
+ * GPG Signer only: The path to a public keyring to add to the list of keyrings. By default, only the
+ * {@code pubring.gpg} from gpg's home directory is considered. Use this option (and {@link #defaultKeyring}
+ * if required) to use a different public key. Note: Relative paths are resolved against gpg's home
+ * directory, not the project base directory.
+ *
+ * NOTE: As of gpg 2.1 this is an obsolete option and ignored. All public keys are stored in the
+ * ‘pubring.kbx’ file below the GnuPG home directory.
*
* @since 1.2
+ * @deprecated
*/
+ @Deprecated
@Parameter(property = "gpg.publicKeyring")
private String publicKeyring;
/**
- * The lock mode to use when invoking gpg. By default no lock mode will be specified. Valid values are {@code once},
- * {@code multiple} and {@code never}. The lock mode gets translated into the corresponding {@code --lock-___}
- * command line argument. Improper usage of this option may lead to data and key corruption.
+ * GPG Signer only: The lock mode to use when invoking gpg. By default no lock mode will be specified. Valid
+ * values are {@code once}, {@code multiple} and {@code never}. The lock mode gets translated into the
+ * corresponding {@code --lock-___} command line argument. Improper usage of this option may lead to data and
+ * key corruption.
*
* @see the
* --lock-options
@@ -163,6 +233,15 @@ public abstract class AbstractGpgMojo extends AbstractMojo {
@Parameter
private List gpgArguments;
+ /**
+ * The name of the Signer implementation to use. Accepted values are {@code "gpg"} (the default, uses GnuPG
+ * executable) and {@code "bc"} (uses Bouncy Castle pure Java signer).
+ *
+ * @since 3.2.0
+ */
+ @Parameter(property = "gpg.signer", defaultValue = GpgSigner.NAME)
+ private String signer;
+
/**
* @since 3.0.0
*/
@@ -188,8 +267,21 @@ public final void execute() throws MojoExecutionException, MojoFailureException
protected abstract void doExecute() throws MojoExecutionException, MojoFailureException;
- protected AbstractGpgSigner newSigner() throws MojoExecutionException, MojoFailureException {
- AbstractGpgSigner signer = new GpgSigner(executable);
+ protected AbstractGpgSigner newSigner() throws MojoFailureException {
+ AbstractGpgSigner signer;
+ if (GpgSigner.NAME.equals(this.signer)) {
+ signer = new GpgSigner(executable);
+ } else if (BcSigner.NAME.equals(this.signer)) {
+ signer = new BcSigner(
+ session.getRepositorySession(),
+ keyEnvName,
+ keyFingerprintEnvName,
+ agentSocketLocations,
+ keyFilePath,
+ keyFingerprint);
+ } else {
+ throw new MojoFailureException("Unknown signer: " + this.signer);
+ }
signer.setLog(getLog());
signer.setInteractive(interactive);
@@ -208,13 +300,14 @@ protected AbstractGpgSigner newSigner() throws MojoExecutionException, MojoFailu
signer.setPassPhrase(passphrase);
}
- signer.setPassPhrase(passphrase);
- if (null == passphrase && !useAgent) {
- if (!interactive) {
- throw new MojoFailureException("Cannot obtain passphrase in batch mode");
- }
+ // gpg signer: always failed if no passphrase and no agent and not interactive: retain this behavior
+ // bc signer: it is optimistic, will fail during prepare() only IF key is passphrase protected
+ if (GpgSigner.NAME.equals(this.signer) && null == passphrase && !useAgent && !interactive) {
+ throw new MojoFailureException("Cannot obtain passphrase in batch mode");
}
+ signer.prepare();
+
return signer;
}
}
diff --git a/src/main/java/org/apache/maven/plugins/gpg/AbstractGpgSigner.java b/src/main/java/org/apache/maven/plugins/gpg/AbstractGpgSigner.java
index d94c3cc..daef26d 100644
--- a/src/main/java/org/apache/maven/plugins/gpg/AbstractGpgSigner.java
+++ b/src/main/java/org/apache/maven/plugins/gpg/AbstractGpgSigner.java
@@ -22,6 +22,7 @@
import java.util.List;
import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugin.logging.Log;
/**
@@ -121,6 +122,10 @@ public void setPublicKeyring(String path) {
publicKeyring = path;
}
+ public abstract String signerName();
+
+ public void prepare() throws MojoFailureException {}
+
/**
* Create a detached signature file for the provided file.
*
diff --git a/src/main/java/org/apache/maven/plugins/gpg/BcSigner.java b/src/main/java/org/apache/maven/plugins/gpg/BcSigner.java
new file mode 100644
index 0000000..3ab8c22
--- /dev/null
+++ b/src/main/java/org/apache/maven/plugins/gpg/BcSigner.java
@@ -0,0 +1,371 @@
+/*
+ * 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.maven.plugins.gpg;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.SocketException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.bouncycastle.bcpg.ArmoredOutputStream;
+import org.bouncycastle.bcpg.BCPGOutputStream;
+import org.bouncycastle.bcpg.HashAlgorithmTags;
+import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPPrivateKey;
+import org.bouncycastle.openpgp.PGPSecretKey;
+import org.bouncycastle.openpgp.PGPSecretKeyRing;
+import org.bouncycastle.openpgp.PGPSecretKeyRingCollection;
+import org.bouncycastle.openpgp.PGPSignature;
+import org.bouncycastle.openpgp.PGPSignatureGenerator;
+import org.bouncycastle.openpgp.PGPSignatureSubpacketGenerator;
+import org.bouncycastle.openpgp.PGPSignatureSubpacketVector;
+import org.bouncycastle.openpgp.PGPUtil;
+import org.bouncycastle.openpgp.operator.bc.BcKeyFingerprintCalculator;
+import org.bouncycastle.openpgp.operator.bc.BcPBESecretKeyDecryptorBuilder;
+import org.bouncycastle.openpgp.operator.bc.BcPGPContentSignerBuilder;
+import org.bouncycastle.openpgp.operator.bc.BcPGPDigestCalculatorProvider;
+import org.bouncycastle.util.encoders.Hex;
+import org.codehaus.plexus.util.io.CachingOutputStream;
+import org.eclipse.aether.RepositorySystemSession;
+import org.newsclub.net.unix.AFUNIXSocket;
+import org.newsclub.net.unix.AFUNIXSocketAddress;
+
+/**
+ * A signer implementation that uses pure Java Bouncy Castle implementation to sign.
+ */
+@SuppressWarnings("checkstyle:magicnumber")
+public class BcSigner extends AbstractGpgSigner {
+ public static final String NAME = "bc";
+
+ public interface Loader {
+ /**
+ * Returns {@code true} if this loader requires user interactivity.
+ */
+ boolean isInteractive();
+
+ /**
+ * Returns the key ring material, or {@code null}.
+ */
+ default byte[] loadKeyRingMaterial(RepositorySystemSession session) throws IOException {
+ return null;
+ }
+
+ /**
+ * Returns the key fingerprint, or {@code null}.
+ */
+ default byte[] loadKeyFingerprint(RepositorySystemSession session) throws IOException {
+ return null;
+ }
+
+ /**
+ * Returns the key password, or {@code null}.
+ */
+ default char[] loadPassword(RepositorySystemSession session, long keyId) throws IOException {
+ return null;
+ }
+ }
+
+ public final class GpgEnvLoader implements Loader {
+ @Override
+ public boolean isInteractive() {
+ return false;
+ }
+
+ @Override
+ public byte[] loadKeyRingMaterial(RepositorySystemSession session) {
+ String keyMaterial = (String) session.getConfigProperties().get("env." + keyEnvName);
+ if (keyMaterial != null) {
+ return keyMaterial.getBytes(StandardCharsets.UTF_8);
+ }
+ return null;
+ }
+
+ @Override
+ public byte[] loadKeyFingerprint(RepositorySystemSession session) {
+ String keyFingerprint = (String) session.getConfigProperties().get("env." + keyFingerprintEnvName);
+ if (keyFingerprint != null) {
+ if (keyFingerprint.trim().length() == 40) {
+ return Hex.decode(keyFingerprint);
+ } else {
+ throw new IllegalArgumentException(
+ "Key fingerprint configuration is wrong (hex encoded, 40 characters)");
+ }
+ }
+ return null;
+ }
+ }
+
+ public final class GpgConfLoader implements Loader {
+ /**
+ * Maximum key size, see Large Keys.
+ */
+ private static final long MAX_SIZE = 5 * 1024 + 1L;
+
+ @Override
+ public boolean isInteractive() {
+ return false;
+ }
+
+ @Override
+ public byte[] loadKeyRingMaterial(RepositorySystemSession session) throws IOException {
+ Path keyPath = Paths.get(keyFilePath);
+ if (!keyPath.isAbsolute()) {
+ keyPath = session.getLocalRepository().getBasedir().toPath().resolve(keyPath);
+ }
+ if (Files.isRegularFile(keyPath)) {
+ if (Files.size(keyPath) < MAX_SIZE) {
+ return Files.readAllBytes(keyPath);
+ } else {
+ throw new IOException("Refusing to load key " + keyPath + "; is larger than 5KB");
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public byte[] loadKeyFingerprint(RepositorySystemSession session) {
+ if (keyFingerprint != null) {
+ if (keyFingerprint.trim().length() == 40) {
+ return Hex.decode(keyFingerprint);
+ } else {
+ throw new IllegalArgumentException(
+ "Key fingerprint configuration is wrong (hex encoded, 40 characters)");
+ }
+ }
+ return null;
+ }
+ }
+
+ public final class GpgAgentPasswordLoader implements Loader {
+ @Override
+ public boolean isInteractive() {
+ return true;
+ }
+
+ @Override
+ public char[] loadPassword(RepositorySystemSession session, long keyId) throws IOException {
+ List socketLocations = Arrays.stream(agentSocketLocations.split(","))
+ .filter(s -> s != null && !s.isEmpty())
+ .collect(Collectors.toList());
+ for (String socketLocation : socketLocations) {
+ try {
+ return load(keyId, Paths.get(System.getProperty("user.home"), socketLocation))
+ .toCharArray();
+ } catch (SocketException e) {
+ // try next location
+ }
+ }
+ return null;
+ }
+
+ private String load(long keyId, Path socketPath) throws IOException {
+ try (AFUNIXSocket sock = AFUNIXSocket.newInstance()) {
+ sock.connect(AFUNIXSocketAddress.of(socketPath));
+ try (BufferedReader in = new BufferedReader(new InputStreamReader(sock.getInputStream()));
+ OutputStream os = sock.getOutputStream()) {
+
+ expectOK(in);
+ String display = System.getenv("DISPLAY");
+ if (display != null) {
+ os.write(("OPTION display=" + display + "\n").getBytes());
+ os.flush();
+ expectOK(in);
+ }
+ String term = System.getenv("TERM");
+ if (term != null) {
+ os.write(("OPTION ttytype=" + term + "\n").getBytes());
+ os.flush();
+ expectOK(in);
+ }
+ String hexKeyId = Long.toHexString(keyId & 0xFFFFFFFFL);
+ // https://unix.stackexchange.com/questions/71135/how-can-i-find-out-what-keys-gpg-agent-has-cached-like-how-ssh-add-l-shows-yo
+ String instruction = "GET_PASSPHRASE " + hexKeyId + " " + "Passphrase+incorrect"
+ + " GnuPG+Key+Passphrase Enter+passphrase+for+encrypted+GnuPG+key+" + hexKeyId
+ + "+to+use+it+for+signing+Maven+Artifacts\n";
+ os.write((instruction).getBytes());
+ os.flush();
+ return new String(Hex.decode(expectOK(in).trim()));
+ }
+ }
+ }
+
+ private String expectOK(BufferedReader in) throws IOException {
+ String response = in.readLine();
+ if (!response.startsWith("OK")) {
+ throw new IOException("Expected OK but got this instead: " + response);
+ }
+ return response.substring(Math.min(response.length(), 3));
+ }
+ }
+
+ private final RepositorySystemSession session;
+ private final String keyEnvName;
+ private final String keyFingerprintEnvName;
+ private final String agentSocketLocations;
+ private final String keyFilePath;
+ private final String keyFingerprint;
+ private PGPSecretKey secretKey;
+ private PGPPrivateKey privateKey;
+ private PGPSignatureSubpacketVector hashSubPackets;
+
+ public BcSigner(
+ RepositorySystemSession session,
+ String keyEnvName,
+ String keyFingerprintEnvName,
+ String agentSocketLocations,
+ String keyFilePath,
+ String keyFingerprint) {
+ this.session = session;
+ this.keyEnvName = keyEnvName;
+ this.keyFingerprintEnvName = keyFingerprintEnvName;
+ this.agentSocketLocations = agentSocketLocations;
+ this.keyFilePath = keyFilePath;
+ this.keyFingerprint = keyFingerprint;
+ }
+
+ @Override
+ public String signerName() {
+ return NAME;
+ }
+
+ @Override
+ public void prepare() throws MojoFailureException {
+ try {
+ List loaders = Stream.of(new GpgEnvLoader(), new GpgConfLoader(), new GpgAgentPasswordLoader())
+ .filter(l -> this.isInteractive || !l.isInteractive())
+ .collect(Collectors.toList());
+
+ byte[] keyRingMaterial = null;
+ for (Loader loader : loaders) {
+ keyRingMaterial = loader.loadKeyRingMaterial(session);
+ if (keyRingMaterial != null) {
+ break;
+ }
+ }
+ if (keyRingMaterial == null) {
+ throw new MojoFailureException("Key ring material not found");
+ }
+
+ byte[] fingerprint = null;
+ for (Loader loader : loaders) {
+ fingerprint = loader.loadKeyFingerprint(session);
+ if (fingerprint != null) {
+ break;
+ }
+ }
+
+ PGPSecretKeyRingCollection pgpSecretKeyRingCollection = new PGPSecretKeyRingCollection(
+ PGPUtil.getDecoderStream(new ByteArrayInputStream(keyRingMaterial)),
+ new BcKeyFingerprintCalculator());
+
+ PGPSecretKey secretKey = null;
+ for (PGPSecretKeyRing ring : pgpSecretKeyRingCollection) {
+ for (PGPSecretKey key : ring) {
+ if (!key.isPrivateKeyEmpty()) {
+ if (fingerprint == null || Arrays.equals(fingerprint, key.getFingerprint())) {
+ secretKey = key;
+ break;
+ }
+ }
+ }
+ }
+ if (secretKey == null) {
+ throw new MojoFailureException("Secret key not found");
+ }
+ if (secretKey.isPrivateKeyEmpty()) {
+ throw new MojoFailureException("Private key not found in Secret key");
+ }
+
+ long validSeconds = secretKey.getPublicKey().getValidSeconds();
+ if (validSeconds > 0) {
+ LocalDateTime expireDateTime = secretKey
+ .getPublicKey()
+ .getCreationTime()
+ .toInstant()
+ .atZone(ZoneId.systemDefault())
+ .toLocalDateTime()
+ .plusSeconds(validSeconds);
+ if (LocalDateTime.now().isAfter(expireDateTime)) {
+ throw new MojoFailureException("Secret key expired at: " + expireDateTime);
+ }
+ }
+
+ char[] keyPassword = passphrase != null ? passphrase.toCharArray() : null;
+ final boolean keyPassNeeded = secretKey.getKeyEncryptionAlgorithm() != SymmetricKeyAlgorithmTags.NULL;
+ if (keyPassNeeded && keyPassword == null) {
+ for (Loader loader : loaders) {
+ keyPassword = loader.loadPassword(session, secretKey.getKeyID());
+ if (keyPassword != null) {
+ break;
+ }
+ }
+ if (keyPassword == null) {
+ throw new MojoFailureException("Secret key is encrypted but no passphrase provided");
+ }
+ }
+
+ this.secretKey = secretKey;
+ this.privateKey = secretKey.extractPrivateKey(
+ new BcPBESecretKeyDecryptorBuilder(new BcPGPDigestCalculatorProvider()).build(keyPassword));
+ PGPSignatureSubpacketGenerator subPacketGenerator = new PGPSignatureSubpacketGenerator();
+ subPacketGenerator.setIssuerFingerprint(false, secretKey);
+ this.hashSubPackets = subPacketGenerator.generate();
+ } catch (PGPException | IOException e) {
+ throw new MojoFailureException(e);
+ }
+ }
+
+ @Override
+ protected void generateSignatureForFile(File file, File signature) throws MojoExecutionException {
+ try (InputStream in = Files.newInputStream(file.toPath());
+ OutputStream out = new CachingOutputStream(signature.toPath())) {
+ PGPSignatureGenerator sGen = new PGPSignatureGenerator(
+ new BcPGPContentSignerBuilder(secretKey.getPublicKey().getAlgorithm(), HashAlgorithmTags.SHA512));
+ sGen.init(PGPSignature.BINARY_DOCUMENT, privateKey);
+ sGen.setHashedSubpackets(hashSubPackets);
+ int len;
+ byte[] buffer = new byte[8 * 1024];
+ while ((len = in.read(buffer)) >= 0) {
+ sGen.update(buffer, 0, len);
+ }
+ try (BCPGOutputStream bcpgOutputStream = new BCPGOutputStream(new ArmoredOutputStream(out))) {
+ sGen.generate().encode(bcpgOutputStream);
+ }
+ } catch (PGPException | IOException e) {
+ throw new MojoExecutionException(e);
+ }
+ }
+}
diff --git a/src/main/java/org/apache/maven/plugins/gpg/GpgSignAttachedMojo.java b/src/main/java/org/apache/maven/plugins/gpg/GpgSignAttachedMojo.java
index 2032b0b..55ae88a 100644
--- a/src/main/java/org/apache/maven/plugins/gpg/GpgSignAttachedMojo.java
+++ b/src/main/java/org/apache/maven/plugins/gpg/GpgSignAttachedMojo.java
@@ -87,8 +87,8 @@ protected void doExecute() throws MojoExecutionException, MojoFailureException {
signer.setBuildDirectory(new File(project.getBuild().getDirectory()));
signer.setBaseDirectory(project.getBasedir());
- getLog().info("Signing " + items.size() + " file" + ((items.size() > 1) ? "s" : "") + " with "
- + ((signer.keyname == null) ? "default" : signer.keyname) + " secret key.");
+ getLog().info("Signer '" + signer.signerName() + "' is signing " + items.size() + " file"
+ + ((items.size() > 1) ? "s" : ""));
for (FilesCollector.Item item : items) {
getLog().debug("Generating signature for " + item.getFile());
diff --git a/src/main/java/org/apache/maven/plugins/gpg/GpgSigner.java b/src/main/java/org/apache/maven/plugins/gpg/GpgSigner.java
index c0f7a96..deaff7b 100644
--- a/src/main/java/org/apache/maven/plugins/gpg/GpgSigner.java
+++ b/src/main/java/org/apache/maven/plugins/gpg/GpgSigner.java
@@ -33,12 +33,18 @@
* A signer implementation that uses the GnuPG command line executable.
*/
public class GpgSigner extends AbstractGpgSigner {
+ public static final String NAME = "gpg";
private String executable;
public GpgSigner(String executable) {
this.executable = executable;
}
+ @Override
+ public String signerName() {
+ return NAME;
+ }
+
/**
* {@inheritDoc}
*/
diff --git a/src/main/java/org/apache/maven/plugins/gpg/SignAndDeployFileMojo.java b/src/main/java/org/apache/maven/plugins/gpg/SignAndDeployFileMojo.java
index c754950..623e3e3 100644
--- a/src/main/java/org/apache/maven/plugins/gpg/SignAndDeployFileMojo.java
+++ b/src/main/java/org/apache/maven/plugins/gpg/SignAndDeployFileMojo.java
@@ -353,6 +353,9 @@ protected void doExecute() throws MojoExecutionException, MojoFailureException {
signer.setOutputDirectory(ascDirectory);
signer.setBaseDirectory(new File("").getAbsoluteFile());
+ getLog().info("Signer '" + signer.signerName() + "' is signing " + artifacts.size() + " file"
+ + ((artifacts.size() > 1) ? "s" : ""));
+
ArrayList signatures = new ArrayList<>();
for (Artifact a : artifacts) {
signatures.add(new DefaultArtifact(
diff --git a/src/test/java/org/apache/maven/plugins/gpg/BcSignerTest.java b/src/test/java/org/apache/maven/plugins/gpg/BcSignerTest.java
new file mode 100644
index 0000000..67d360d
--- /dev/null
+++ b/src/test/java/org/apache/maven/plugins/gpg/BcSignerTest.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.maven.plugins.gpg;
+
+import java.io.File;
+
+import org.eclipse.aether.DefaultRepositorySystemSession;
+import org.eclipse.aether.internal.impl.DefaultLocalPathComposer;
+import org.eclipse.aether.internal.impl.SimpleLocalRepositoryManagerFactory;
+import org.eclipse.aether.repository.LocalRepository;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link BcSigner}.
+ */
+class BcSignerTest {
+
+ /**
+ * Test for BC agent use. Disabled, as this test cannot run on CI, only on computer that have gpg-agent.
+ * The goal of this test is to prepare BC signer, but to be able to prepare, it needs passphrase for the
+ * passphrase protected signing key (provided in src/test/resources/signing-key.asc). Passphrase is "TEST"
+ * (without quotes, all caps). If you want to execute this test, remove disabled annotation and run it from
+ * IDE (or whatever is your preferred way). On first run, Agent will pop a dialogue asking for password,
+ * and it will cache your response, so subsequent invocation will NOT ask for password.
+ *
+ * IF you enter correct password ("TEST"), the test will pass (prepare will execute without any issue).
+ * IF you enter incorrect password, the test will fail with some message like:
+ * {@code org.apache.maven.plugin.MojoFailureException: org.bouncycastle.openpgp.PGPException: checksum mismatch at in checksum of 20 bytes}
+ * and this would cause plugin failure as well.
+ *
+ * On Un*x, to make agent "forget" what you entered, use {@code gpg-connect-agent RELOADAGENT} command. To exit use
+ * Ctrl+D (EOF).
+ */
+ @Disabled
+ @Test
+ void testAgent() throws Exception {
+ DefaultRepositorySystemSession session = new DefaultRepositorySystemSession();
+ session.setLocalRepositoryManager(new SimpleLocalRepositoryManagerFactory(new DefaultLocalPathComposer())
+ .newInstance(session, new LocalRepository("target/local-repo")));
+ BcSigner signer = new BcSigner(
+ session,
+ "unimportant",
+ "unimportant",
+ ".gnupg/S.gpg-agent",
+ new File("src/test/resources/signing-key.asc").getAbsolutePath(),
+ null);
+ signer.prepare();
+ }
+}
diff --git a/src/test/java/org/apache/maven/plugins/gpg/it/BcSignArtifactIT.java b/src/test/java/org/apache/maven/plugins/gpg/it/BcSignArtifactIT.java
new file mode 100644
index 0000000..91aef65
--- /dev/null
+++ b/src/test/java/org/apache/maven/plugins/gpg/it/BcSignArtifactIT.java
@@ -0,0 +1,81 @@
+/*
+ * 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.maven.plugins.gpg.it;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.Collection;
+
+import org.apache.maven.shared.invoker.InvocationRequest;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class BcSignArtifactIT extends ITSupport {
+ public static Collection