View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.plugins.gpg;
20  
21  import java.io.BufferedReader;
22  import java.io.ByteArrayInputStream;
23  import java.io.File;
24  import java.io.IOException;
25  import java.io.InputStream;
26  import java.io.InputStreamReader;
27  import java.io.OutputStream;
28  import java.net.SocketException;
29  import java.nio.charset.StandardCharsets;
30  import java.nio.file.Files;
31  import java.nio.file.Path;
32  import java.nio.file.Paths;
33  import java.time.LocalDateTime;
34  import java.time.ZoneId;
35  import java.util.Arrays;
36  import java.util.List;
37  import java.util.Locale;
38  import java.util.stream.Collectors;
39  import java.util.stream.Stream;
40  
41  import org.apache.maven.plugin.MojoExecutionException;
42  import org.apache.maven.plugin.MojoFailureException;
43  import org.bouncycastle.bcpg.ArmoredOutputStream;
44  import org.bouncycastle.bcpg.BCPGOutputStream;
45  import org.bouncycastle.bcpg.HashAlgorithmTags;
46  import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags;
47  import org.bouncycastle.openpgp.PGPException;
48  import org.bouncycastle.openpgp.PGPPrivateKey;
49  import org.bouncycastle.openpgp.PGPSecretKey;
50  import org.bouncycastle.openpgp.PGPSecretKeyRing;
51  import org.bouncycastle.openpgp.PGPSecretKeyRingCollection;
52  import org.bouncycastle.openpgp.PGPSignature;
53  import org.bouncycastle.openpgp.PGPSignatureGenerator;
54  import org.bouncycastle.openpgp.PGPSignatureSubpacketGenerator;
55  import org.bouncycastle.openpgp.PGPSignatureSubpacketVector;
56  import org.bouncycastle.openpgp.PGPUtil;
57  import org.bouncycastle.openpgp.operator.bc.BcKeyFingerprintCalculator;
58  import org.bouncycastle.openpgp.operator.bc.BcPBESecretKeyDecryptorBuilder;
59  import org.bouncycastle.openpgp.operator.bc.BcPGPContentSignerBuilder;
60  import org.bouncycastle.openpgp.operator.bc.BcPGPDigestCalculatorProvider;
61  import org.bouncycastle.util.encoders.Hex;
62  import org.codehaus.plexus.util.io.CachingOutputStream;
63  import org.eclipse.aether.RepositorySystemSession;
64  import org.newsclub.net.unix.AFUNIXSocket;
65  import org.newsclub.net.unix.AFUNIXSocketAddress;
66  
67  /**
68   * A signer implementation that uses pure Java Bouncy Castle implementation to sign.
69   */
70  @SuppressWarnings("checkstyle:magicnumber")
71  public class BcSigner extends AbstractGpgSigner {
72      public static final String NAME = "bc";
73  
74      public interface Loader {
75          /**
76           * Returns the key ring material, or {@code null}.
77           */
78          default byte[] loadKeyRingMaterial(RepositorySystemSession session) throws IOException {
79              return null;
80          }
81  
82          /**
83           * Returns the key fingerprint, or {@code null}.
84           */
85          default byte[] loadKeyFingerprint(RepositorySystemSession session) throws IOException {
86              return null;
87          }
88  
89          /**
90           * Returns the key password, or {@code null}.
91           */
92          default char[] loadPassword(RepositorySystemSession session, byte[] fingerprint) throws IOException {
93              return null;
94          }
95      }
96  
97      public final class GpgEnvLoader implements Loader {
98          @Override
99          public byte[] loadKeyRingMaterial(RepositorySystemSession session) {
100             String keyMaterial = (String) session.getConfigProperties().get("env." + keyEnvName);
101             if (keyMaterial != null) {
102                 return keyMaterial.getBytes(StandardCharsets.UTF_8);
103             }
104             return null;
105         }
106 
107         @Override
108         public byte[] loadKeyFingerprint(RepositorySystemSession session) {
109             String keyFingerprint = (String) session.getConfigProperties().get("env." + keyFingerprintEnvName);
110             if (keyFingerprint != null) {
111                 if (keyFingerprint.trim().length() == 40) {
112                     return Hex.decode(keyFingerprint);
113                 } else {
114                     throw new IllegalArgumentException(
115                             "Key fingerprint configuration is wrong (hex encoded, 40 characters)");
116                 }
117             }
118             return null;
119         }
120     }
121 
122     public final class GpgConfLoader implements Loader {
123         /**
124          * Maximum key size, see <a href="https://wiki.gnupg.org/LargeKeys">Large Keys</a>.
125          */
126         private static final long MAX_SIZE = 5 * 1024 + 1L;
127 
128         @Override
129         public byte[] loadKeyRingMaterial(RepositorySystemSession session) throws IOException {
130             Path keyPath = Paths.get(keyFilePath);
131             if (!keyPath.isAbsolute()) {
132                 keyPath = Paths.get(System.getProperty("user.home"))
133                         .resolve(keyPath)
134                         .toAbsolutePath();
135             }
136             if (Files.isRegularFile(keyPath)) {
137                 if (Files.size(keyPath) < MAX_SIZE) {
138                     return Files.readAllBytes(keyPath);
139                 } else {
140                     throw new IOException("Refusing to load key " + keyPath + "; is larger than 5KB");
141                 }
142             }
143             return null;
144         }
145 
146         @Override
147         public byte[] loadKeyFingerprint(RepositorySystemSession session) {
148             if (keyFingerprint != null) {
149                 if (keyFingerprint.trim().length() == 40) {
150                     return Hex.decode(keyFingerprint);
151                 } else {
152                     throw new IllegalArgumentException(
153                             "Key fingerprint configuration is wrong (hex encoded, 40 characters)");
154                 }
155             }
156             return null;
157         }
158     }
159 
160     public final class GpgAgentPasswordLoader implements Loader {
161         @Override
162         public char[] loadPassword(RepositorySystemSession session, byte[] fingerprint) throws IOException {
163             if (!useAgent) {
164                 return null;
165             }
166             List<String> socketLocations = Arrays.stream(agentSocketLocations.split(","))
167                     .filter(s -> s != null && !s.isEmpty())
168                     .collect(Collectors.toList());
169             for (String socketLocation : socketLocations) {
170                 try {
171                     Path socketLocationPath = Paths.get(socketLocation);
172                     if (!socketLocationPath.isAbsolute()) {
173                         socketLocationPath = Paths.get(System.getProperty("user.home"))
174                                 .resolve(socketLocationPath)
175                                 .toAbsolutePath();
176                     }
177                     String pw = load(fingerprint, socketLocationPath);
178                     if (pw != null) {
179                         return pw.toCharArray();
180                     }
181                 } catch (SocketException e) {
182                     // try next location
183                 }
184             }
185             return null;
186         }
187 
188         private String load(byte[] fingerprint, Path socketPath) throws IOException {
189             try (AFUNIXSocket sock = AFUNIXSocket.newInstance()) {
190                 sock.connect(AFUNIXSocketAddress.of(socketPath));
191                 try (BufferedReader in = new BufferedReader(new InputStreamReader(sock.getInputStream()));
192                         OutputStream os = sock.getOutputStream()) {
193 
194                     expectOK(in);
195                     String display = System.getenv("DISPLAY");
196                     if (display != null) {
197                         os.write(("OPTION display=" + display + "\n").getBytes());
198                         os.flush();
199                         expectOK(in);
200                     }
201                     String term = System.getenv("TERM");
202                     if (term != null) {
203                         os.write(("OPTION ttytype=" + term + "\n").getBytes());
204                         os.flush();
205                         expectOK(in);
206                     }
207                     String hexKeyFingerprint = Hex.toHexString(fingerprint);
208                     String displayFingerprint = hexKeyFingerprint.toUpperCase(Locale.ROOT);
209                     // https://unix.stackexchange.com/questions/71135/how-can-i-find-out-what-keys-gpg-agent-has-cached-like-how-ssh-add-l-shows-yo
210                     String instruction = "GET_PASSPHRASE "
211                             + (!isInteractive ? "--no-ask " : "")
212                             + hexKeyFingerprint
213                             + " "
214                             + "X "
215                             + "GnuPG+Passphrase "
216                             + "Please+enter+the+passphrase+to+unlock+the+OpenPGP+secret+key+with+fingerprint:+"
217                             + displayFingerprint
218                             + "+to+use+it+for+signing+Maven+Artifacts\n";
219                     os.write((instruction).getBytes());
220                     os.flush();
221                     String pw = mayExpectOK(in);
222                     if (pw != null) {
223                         return new String(Hex.decode(pw.trim()));
224                     }
225                     return null;
226                 }
227             }
228         }
229 
230         private void expectOK(BufferedReader in) throws IOException {
231             String response = in.readLine();
232             if (!response.startsWith("OK")) {
233                 throw new IOException("Expected OK but got this instead: " + response);
234             }
235         }
236 
237         private String mayExpectOK(BufferedReader in) throws IOException {
238             String response = in.readLine();
239             if (response.startsWith("ERR")) {
240                 return null;
241             } else if (!response.startsWith("OK")) {
242                 throw new IOException("Expected OK/ERR but got this instead: " + response);
243             }
244             return response.substring(Math.min(response.length(), 3));
245         }
246     }
247 
248     private final RepositorySystemSession session;
249     private final String keyEnvName;
250     private final String keyFingerprintEnvName;
251     private final String agentSocketLocations;
252     private final String keyFilePath;
253     private final String keyFingerprint;
254     private PGPSecretKey secretKey;
255     private PGPPrivateKey privateKey;
256     private PGPSignatureSubpacketVector hashSubPackets;
257 
258     public BcSigner(
259             RepositorySystemSession session,
260             String keyEnvName,
261             String keyFingerprintEnvName,
262             String agentSocketLocations,
263             String keyFilePath,
264             String keyFingerprint) {
265         this.session = session;
266         this.keyEnvName = keyEnvName;
267         this.keyFingerprintEnvName = keyFingerprintEnvName;
268         this.agentSocketLocations = agentSocketLocations;
269         this.keyFilePath = keyFilePath;
270         this.keyFingerprint = keyFingerprint;
271     }
272 
273     @Override
274     public String signerName() {
275         return NAME;
276     }
277 
278     @Override
279     public void prepare() throws MojoFailureException {
280         try {
281             List<Loader> loaders = Stream.of(new GpgEnvLoader(), new GpgConfLoader(), new GpgAgentPasswordLoader())
282                     .collect(Collectors.toList());
283 
284             byte[] keyRingMaterial = null;
285             for (Loader loader : loaders) {
286                 keyRingMaterial = loader.loadKeyRingMaterial(session);
287                 if (keyRingMaterial != null) {
288                     break;
289                 }
290             }
291             if (keyRingMaterial == null) {
292                 throw new MojoFailureException("Key ring material not found");
293             }
294 
295             byte[] fingerprint = null;
296             for (Loader loader : loaders) {
297                 fingerprint = loader.loadKeyFingerprint(session);
298                 if (fingerprint != null) {
299                     break;
300                 }
301             }
302 
303             PGPSecretKeyRingCollection pgpSecretKeyRingCollection = new PGPSecretKeyRingCollection(
304                     PGPUtil.getDecoderStream(new ByteArrayInputStream(keyRingMaterial)),
305                     new BcKeyFingerprintCalculator());
306 
307             PGPSecretKey secretKey = null;
308             for (PGPSecretKeyRing ring : pgpSecretKeyRingCollection) {
309                 for (PGPSecretKey key : ring) {
310                     if (!key.isPrivateKeyEmpty()) {
311                         if (fingerprint == null || Arrays.equals(fingerprint, key.getFingerprint())) {
312                             secretKey = key;
313                             break;
314                         }
315                     }
316                 }
317             }
318             if (secretKey == null) {
319                 throw new MojoFailureException("Secret key not found");
320             }
321             if (secretKey.isPrivateKeyEmpty()) {
322                 throw new MojoFailureException("Private key not found in Secret key");
323             }
324 
325             long validSeconds = secretKey.getPublicKey().getValidSeconds();
326             if (validSeconds > 0) {
327                 LocalDateTime expireDateTime = secretKey
328                         .getPublicKey()
329                         .getCreationTime()
330                         .toInstant()
331                         .atZone(ZoneId.systemDefault())
332                         .toLocalDateTime()
333                         .plusSeconds(validSeconds);
334                 if (LocalDateTime.now().isAfter(expireDateTime)) {
335                     throw new MojoFailureException("Secret key expired at: " + expireDateTime);
336                 }
337             }
338 
339             char[] keyPassword = passphrase != null ? passphrase.toCharArray() : null;
340             final boolean keyPassNeeded = secretKey.getKeyEncryptionAlgorithm() != SymmetricKeyAlgorithmTags.NULL;
341             if (keyPassNeeded && keyPassword == null) {
342                 for (Loader loader : loaders) {
343                     keyPassword = loader.loadPassword(session, secretKey.getFingerprint());
344                     if (keyPassword != null) {
345                         break;
346                     }
347                 }
348                 if (keyPassword == null) {
349                     throw new MojoFailureException("Secret key is encrypted but no passphrase provided");
350                 }
351             }
352 
353             this.secretKey = secretKey;
354             this.privateKey = secretKey.extractPrivateKey(
355                     new BcPBESecretKeyDecryptorBuilder(new BcPGPDigestCalculatorProvider()).build(keyPassword));
356             PGPSignatureSubpacketGenerator subPacketGenerator = new PGPSignatureSubpacketGenerator();
357             subPacketGenerator.setIssuerFingerprint(false, secretKey);
358             this.hashSubPackets = subPacketGenerator.generate();
359         } catch (PGPException | IOException e) {
360             throw new MojoFailureException(e);
361         }
362     }
363 
364     @Override
365     protected void generateSignatureForFile(File file, File signature) throws MojoExecutionException {
366         try (InputStream in = Files.newInputStream(file.toPath());
367                 OutputStream out = new CachingOutputStream(signature.toPath())) {
368             PGPSignatureGenerator sGen = new PGPSignatureGenerator(
369                     new BcPGPContentSignerBuilder(secretKey.getPublicKey().getAlgorithm(), HashAlgorithmTags.SHA512));
370             sGen.init(PGPSignature.BINARY_DOCUMENT, privateKey);
371             sGen.setHashedSubpackets(hashSubPackets);
372             int len;
373             byte[] buffer = new byte[8 * 1024];
374             while ((len = in.read(buffer)) >= 0) {
375                 sGen.update(buffer, 0, len);
376             }
377             try (BCPGOutputStream bcpgOutputStream = new BCPGOutputStream(new ArmoredOutputStream(out))) {
378                 sGen.generate().encode(bcpgOutputStream);
379             }
380         } catch (PGPException | IOException e) {
381             throw new MojoExecutionException(e);
382         }
383     }
384 }