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