From 6f50819233cb368edba8335d7cb82edf2e7f2659 Mon Sep 17 00:00:00 2001 From: Tamas Cservenak Date: Mon, 4 Mar 2024 15:50:36 +0100 Subject: [PATCH] [MGPG-106] Introduce new signer: BC (#72) This introduces new pure Java signer BC backed one. Currently two signers supported: "gpg" (uses external executable, as before), and "bc" (uses pure Java Bouncy Castle backed signer). All the ITs (invoker and surefire) are _reused_ and now run twice, once with "gpg" and once with "bc". One IT needed adjustment, as BC does not emit error about "pinentry". --- https://issues.apache.org/jira/browse/MGPG-106 --- pgp-keys-map.list | 5 + pom.xml | 105 +++-- .../verify.groovy | 6 +- .../maven/plugins/gpg/AbstractGpgMojo.java | 149 +++++-- .../maven/plugins/gpg/AbstractGpgSigner.java | 5 + .../apache/maven/plugins/gpg/BcSigner.java | 371 ++++++++++++++++++ .../plugins/gpg/GpgSignAttachedMojo.java | 4 +- .../apache/maven/plugins/gpg/GpgSigner.java | 6 + .../plugins/gpg/SignAndDeployFileMojo.java | 3 + .../maven/plugins/gpg/BcSignerTest.java | 66 ++++ .../plugins/gpg/it/BcSignArtifactIT.java | 81 ++++ .../plugins/gpg/it/GpgSignArtifactIT.java | 17 +- .../plugins/gpg/it/GpgSignAttachedMojoIT.java | 18 +- .../maven/plugins/gpg/it/ITSupport.java | 38 ++ .../plugins/gpg/it/InvokerTestUtils.java | 6 +- src/test/resources/signing-key.asc | 32 ++ 16 files changed, 825 insertions(+), 87 deletions(-) create mode 100644 src/main/java/org/apache/maven/plugins/gpg/BcSigner.java create mode 100644 src/test/java/org/apache/maven/plugins/gpg/BcSignerTest.java create mode 100644 src/test/java/org/apache/maven/plugins/gpg/it/BcSignArtifactIT.java create mode 100644 src/test/java/org/apache/maven/plugins/gpg/it/ITSupport.java create mode 100644 src/test/resources/signing-key.asc 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. + 8 3.9.6 1.9.18 - 8 + 1.77 2023-05-03T01:33:44Z @ @@ -120,6 +121,22 @@ under the License. plexus-utils 3.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.plugins maven-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 data() { + return Arrays.asList(new Object[][] { + { + "/it/sign-release-in-default-dir/pom.xml", + "/target/gpg/tarballs/", + new String[] {"sign-release-in-default-dir-1.0.jar.asc"} + }, + { + "/it/sign-release-in-output-dir/pom.xml", + "/target/signed-files/tarballs/", + new String[] {"sign-release-in-output-dir-1.0.jar.asc"} + }, + { + "/it/sign-release-in-root-dir/pom.xml", + "/signed-files/tarballs/", + new String[] {"sign-release-in-root-dir-1.0.jar.asc"} + }, + { + "/it/sign-release-in-same-dir/pom.xml", + "/target/tarballs/", + new String[] {"sign-release-in-same-dir-1.0.jar", "sign-release-in-same-dir-1.0.jar.asc"} + }, + }); + } + + @MethodSource("data") + @ParameterizedTest + void testPlacementOfArtifactInOutputDirectory(String pomPath, String expectedFileLocation, String[] expectedFiles) + throws Exception { + // given + final File pomFile = InvokerTestUtils.getTestResource(pomPath); + final InvocationRequest request = + InvokerTestUtils.createRequest(pomFile, mavenUserSettings, gpgHome, "bc", true); + final File integrationTestRootDirectory = new File(pomFile.getParent()); + final File expectedOutputDirectory = new File(integrationTestRootDirectory + expectedFileLocation); + + // when + InvokerTestUtils.executeRequest(request, mavenHome, localRepository); + + // then + assertTrue(expectedOutputDirectory.isDirectory()); + + String[] outputFiles = expectedOutputDirectory.list(); + assertNotNull(outputFiles); + + Arrays.sort(outputFiles); + Arrays.sort(expectedFiles); + assertEquals(Arrays.toString(expectedFiles), Arrays.toString(outputFiles)); + } +} diff --git a/src/test/java/org/apache/maven/plugins/gpg/it/GpgSignArtifactIT.java b/src/test/java/org/apache/maven/plugins/gpg/it/GpgSignArtifactIT.java index c9b4ab6..69160ed 100644 --- a/src/test/java/org/apache/maven/plugins/gpg/it/GpgSignArtifactIT.java +++ b/src/test/java/org/apache/maven/plugins/gpg/it/GpgSignArtifactIT.java @@ -30,19 +30,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -public class GpgSignArtifactIT { - private final File mavenHome; - private final File localRepository; - private final File mavenUserSettings; - private final File gpgHome; - - public GpgSignArtifactIT() throws Exception { - this.mavenHome = new File(System.getProperty("maven.home")); - this.localRepository = new File(System.getProperty("localRepositoryPath")); - this.mavenUserSettings = InvokerTestUtils.getTestResource("/it/settings.xml"); - this.gpgHome = new File(System.getProperty("gpg.homedir")); - } - +public class GpgSignArtifactIT extends ITSupport { public static Collection data() { return Arrays.asList(new Object[][] { { @@ -74,7 +62,8 @@ void testPlacementOfArtifactInOutputDirectory(String pomPath, String expectedFil throws Exception { // given final File pomFile = InvokerTestUtils.getTestResource(pomPath); - final InvocationRequest request = InvokerTestUtils.createRequest(pomFile, mavenUserSettings, gpgHome, true); + final InvocationRequest request = + InvokerTestUtils.createRequest(pomFile, mavenUserSettings, gpgHome, "gpg", true); final File integrationTestRootDirectory = new File(pomFile.getParent()); final File expectedOutputDirectory = new File(integrationTestRootDirectory + expectedFileLocation); diff --git a/src/test/java/org/apache/maven/plugins/gpg/it/GpgSignAttachedMojoIT.java b/src/test/java/org/apache/maven/plugins/gpg/it/GpgSignAttachedMojoIT.java index d75880d..c13e1c1 100644 --- a/src/test/java/org/apache/maven/plugins/gpg/it/GpgSignAttachedMojoIT.java +++ b/src/test/java/org/apache/maven/plugins/gpg/it/GpgSignAttachedMojoIT.java @@ -28,26 +28,14 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -public class GpgSignAttachedMojoIT { - - private final File mavenHome; - private final File localRepository; - private final File mavenUserSettings; - private final File gpgHome; - - public GpgSignAttachedMojoIT() throws Exception { - this.mavenHome = new File(System.getProperty("maven.home")); - this.localRepository = new File(System.getProperty("localRepositoryPath")); - this.mavenUserSettings = InvokerTestUtils.getTestResource(System.getProperty("settingsFile")); - this.gpgHome = new File(System.getProperty("gpg.homedir")); - } - +public class GpgSignAttachedMojoIT extends ITSupport { @Test void testInteractiveWithoutPassphrase() throws Exception { // given final File pomFile = InvokerTestUtils.getTestResource("/it/sign-release-without-passphrase-interactive/pom.xml"); - final InvocationRequest request = InvokerTestUtils.createRequest(pomFile, mavenUserSettings, gpgHome, false); + final InvocationRequest request = + InvokerTestUtils.createRequest(pomFile, mavenUserSettings, gpgHome, "gpg", false); // require Maven interactive mode request.setBatchMode(false); diff --git a/src/test/java/org/apache/maven/plugins/gpg/it/ITSupport.java b/src/test/java/org/apache/maven/plugins/gpg/it/ITSupport.java new file mode 100644 index 0000000..4741266 --- /dev/null +++ b/src/test/java/org/apache/maven/plugins/gpg/it/ITSupport.java @@ -0,0 +1,38 @@ +/* + * 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 org.junit.jupiter.api.BeforeEach; + +public abstract class ITSupport { + protected File mavenHome; + protected File localRepository; + protected File mavenUserSettings; + protected File gpgHome; + + @BeforeEach + public void prepare() throws Exception { + this.mavenHome = new File(System.getProperty("maven.home")); + this.localRepository = new File(System.getProperty("localRepositoryPath")); + this.mavenUserSettings = InvokerTestUtils.getTestResource(System.getProperty("settingsFile")); + this.gpgHome = new File(System.getProperty("gpg.homedir")); + } +} diff --git a/src/test/java/org/apache/maven/plugins/gpg/it/InvokerTestUtils.java b/src/test/java/org/apache/maven/plugins/gpg/it/InvokerTestUtils.java index 2c642e9..89bbeea 100644 --- a/src/test/java/org/apache/maven/plugins/gpg/it/InvokerTestUtils.java +++ b/src/test/java/org/apache/maven/plugins/gpg/it/InvokerTestUtils.java @@ -41,7 +41,7 @@ public class InvokerTestUtils { public static InvocationRequest createRequest( - File pomFile, File mavenUserSettings, File gpgHome, boolean providePassphraseEnv) { + File pomFile, File mavenUserSettings, File gpgHome, String signer, boolean providePassphraseEnv) { final InvocationRequest request = new DefaultInvocationRequest(); request.setUserSettingsFile(mavenUserSettings); request.setShowVersion(true); @@ -64,6 +64,10 @@ public static InvocationRequest createRequest( properties.setProperty("https.protocols", httpsProtocols); } + if (signer != null) { + properties.setProperty("gpg.signer", signer); + properties.setProperty("gpg.keyFilePath", new File("src/test/resources/signing-key.asc").getAbsolutePath()); + } properties.setProperty("gpg.homedir", gpgHome.getAbsolutePath()); return request; diff --git a/src/test/resources/signing-key.asc b/src/test/resources/signing-key.asc new file mode 100644 index 0000000..9f9bcbd --- /dev/null +++ b/src/test/resources/signing-key.asc @@ -0,0 +1,32 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- + +lQHpBEqeoMkRBADgRe1HMAJ1LdxnVgfXijYTtlSBe7gYo5SqYvzvi26xUYsgOF96 +N4xXnf+aNKL4DZ+onPqpR1SdNu4Dc78vZGrSzyAjrpLuxODIr5r/xCmOzRrzM8nU +GkP8KVSvcK9V5dHrwZZH+N340BptiMwTJIrvuUOArzHEOc00Bh0pXowSmwCgwN7w +u/uvC2EGpTghHV+ht1VM53cEALmXyodsYQsIdSFPh1slK14Gwo8sipag7rhypek3 +OPXyW1UfysiVXVh0deWbbiFSx/i2yWq1AumTPPfTRyRGWAIFAeRV6Vniil4ufq1a +Un2yeYpGo/cVT+DyQYP1OW6/+/mAjbbEtZauQeUEjVa3j4SqyR10sS4yebJ+ctPQ +Bbe/A/9v5bTjl/u9TbiImbM34K19tBIe3jTQt/V6Tj1mZ8P/ssVa7BEgapp3/BY6 +4V48SkhxumEjHcy1wc3njLMb8Xbv9js2O6bpG84CBP3IN9iZOz7ANhxBuwG5LJN2 +0bEcQsE3gCNLx8Sz2A4Z/8sZfJzq5NdGmiX2X0L3EfF+pTMlKv4HAwKMaiQPAe58 +Xf97gsMrVrd6ruUzLYyUFNXwino8MD9uQS/URJ+hVGAM6WvD3WKMKaHGYt8DgeTM +bJZASGOYMP2j4b3JtDVNYXZlbiBHUEcgUGx1Z2luIChURVNUSU5HIEtFWSkgPGRl +dkBtYXZlbi5hcGFjaGUub3JnPohgBBMRAgAgBQJKnqDJAhsDBgsJCAcDAgQVAggD +BBYCAwECHgECF4AACgkQg8qoh2UlSiYJpACeOfzjOBNIzmbfdDHqA9iY33BwgxQA +n1U07P548XAkochaezFB2Cr/ZeGTnQJrBEqeoMkQCACKRwro2fnPATtzEcpsvt0j +XMF7WgQcYFUYed5DZ6ra7bVp5yOyZmDswHlhSBXjYWgLBps5FEzawly5mbIc7Wh5 ++izUtD8LiyUWcRiCAvoBOF9J0HKXHhnZxkM0XK6BrSRlARr7HsEpTOosFUPoHsMX +xixwd2WzmV6U3A+4cOnjrnZD5YUJhKU/5JwuP+ZRZpyiyR1Q2qdQdaODy5zvM84q +i2GuORGdYWs5hlkV4ur8ihu7zErQD9sBXPhlzFqRUM/2L57z2wUgDLoNgFzCQSZp +1Rtb0VZvIjhXZ0vquJ8UKNWVpuhzlj7qOdNFHwEA6pRUy/fquk3f1vgo1x5SZv0X +AAMFB/9u0u+BCOJH00762Tr1hClzuzPkQQpaWAmSqbDfj7Lb/0KKlBfOorby1Gdl +Lz1XqsbkwzboDob7hbuAI5YV6iBYEVW8wYI49h+jA1cPFDj2kb1tT4RQrpvfP7GA +n1/GdKHDGupTr2ZoKgU6iEb86ru5gcGbPPItx7rlPDMPGEbCvFtuPImNoscQMh1F +ONHt+8YPJBTYVJFrsFag+p5JXsZWReBsnER5znhHoH8PTgZAGhdMWnKshqESFrNL +rgz9JpGguJ1TL+suTG9CPubCnvXpsMGXTMURYSomwfQD4lXhiwx3DTBL1LXpFbTq +iNBb/jaiJ5sTaGuwTp2+tppPiJ4//gcDAhi2Ufij/zA1/0UivR37hD8OQD4kIJDF +TnFd6hYdJcRJdJAV8hI7ai4f6My7ihC29WoXBrCN2Uaizno2KCBt7m4hUSzLWhSm +audxW4oPzGJwMu9Ux7+m4GCfVYhJBBgRAgAJBQJKnqDJAhsMAAoJEIPKqIdlJUom +IxcAnjPrJT6I0XPlfKuz23x6KFinX4XtAJ40KkIfgDWI2Smg85Fc24E7QRqReg== +=1WyT +-----END PGP PRIVATE KEY BLOCK----- \ No newline at end of file