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 key size, see <a href="https://wiki.gnupg.org/LargeKeys">Large Keys</a>.
126          */
127         private static final long MAX_SIZE = 16 * 1024 + 1L;
128 
129         @Override
130         public byte[] loadKeyRingMaterial(RepositorySystemSession session) throws IOException {
131             Path keyPath = Paths.get(keyFilePath);
132             if (!keyPath.isAbsolute()) {
133                 keyPath = Paths.get(System.getProperty("user.home"))
134                         .resolve(keyPath)
135                         .toAbsolutePath();
136             }
137             if (Files.isRegularFile(keyPath)) {
138                 if (Files.size(keyPath) < MAX_SIZE) {
139                     return Files.readAllBytes(keyPath);
140                 } else {
141                     throw new IOException("Refusing to load key " + keyPath + "; is larger than 16KB");
142                 }
143             }
144             return null;
145         }
146 
147         @Override
148         public byte[] loadKeyFingerprint(RepositorySystemSession session) {
149             if (keyFingerprint != null) {
150                 if (keyFingerprint.trim().length() == 40) {
151                     return Hex.decode(keyFingerprint);
152                 } else {
153                     throw new IllegalArgumentException(
154                             "Key fingerprint configuration is wrong (hex encoded, 40 characters)");
155                 }
156             }
157             return null;
158         }
159     }
160 
161     public final class GpgAgentPasswordLoader implements Loader {
162         @Override
163         public char[] loadPassword(RepositorySystemSession session, byte[] fingerprint) throws IOException {
164             if (!useAgent) {
165                 return null;
166             }
167             List<String> socketLocations = Arrays.stream(agentSocketLocations.split(","))
168                     .filter(s -> s != null && !s.isEmpty())
169                     .collect(Collectors.toList());
170             for (String socketLocation : socketLocations) {
171                 try {
172                     Path socketLocationPath = Paths.get(socketLocation);
173                     if (!socketLocationPath.isAbsolute()) {
174                         socketLocationPath = Paths.get(System.getProperty("user.home"))
175                                 .resolve(socketLocationPath)
176                                 .toAbsolutePath();
177                     }
178                     String pw = load(fingerprint, socketLocationPath);
179                     if (pw != null) {
180                         return pw.toCharArray();
181                     }
182                 } catch (SocketException e) {
183                     // try next location
184                 }
185             }
186             return null;
187         }
188 
189         private String load(byte[] fingerprint, Path socketPath) throws IOException {
190             try (AFUNIXSocket sock = AFUNIXSocket.newInstance()) {
191                 sock.connect(AFUNIXSocketAddress.of(socketPath));
192                 try (BufferedReader in = new BufferedReader(new InputStreamReader(sock.getInputStream()));
193                         OutputStream os = sock.getOutputStream()) {
194 
195                     expectOK(in);
196                     String display = System.getenv("DISPLAY");
197                     if (display != null) {
198                         os.write(("OPTION display=" + display + "\n").getBytes());
199                         os.flush();
200                         expectOK(in);
201                     }
202                     String term = System.getenv("TERM");
203                     if (term != null) {
204                         os.write(("OPTION ttytype=" + term + "\n").getBytes());
205                         os.flush();
206                         expectOK(in);
207                     }
208                     String hexKeyFingerprint = Hex.toHexString(fingerprint);
209                     String displayFingerprint = hexKeyFingerprint.toUpperCase(Locale.ROOT);
210                     // https://unix.stackexchange.com/questions/71135/how-can-i-find-out-what-keys-gpg-agent-has-cached-like-how-ssh-add-l-shows-yo
211                     String instruction = "GET_PASSPHRASE "
212                             + (!isInteractive ? "--no-ask " : "")
213                             + hexKeyFingerprint
214                             + " "
215                             + "X "
216                             + "GnuPG+Passphrase "
217                             + "Please+enter+the+passphrase+to+unlock+the+OpenPGP+secret+key+with+fingerprint:+"
218                             + displayFingerprint
219                             + "+to+use+it+for+signing+Maven+Artifacts\n";
220                     os.write((instruction).getBytes());
221                     os.flush();
222                     String pw = mayExpectOK(in);
223                     if (pw != null) {
224                         return new String(Hex.decode(pw.trim()));
225                     }
226                     return null;
227                 }
228             }
229         }
230 
231         private void expectOK(BufferedReader in) throws IOException {
232             String response = in.readLine();
233             if (!response.startsWith("OK")) {
234                 throw new IOException("Expected OK but got this instead: " + response);
235             }
236         }
237 
238         private String mayExpectOK(BufferedReader in) throws IOException {
239             String response = in.readLine();
240             if (response.startsWith("ERR")) {
241                 return null;
242             } else if (!response.startsWith("OK")) {
243                 throw new IOException("Expected OK/ERR but got this instead: " + response);
244             }
245             return response.substring(Math.min(response.length(), 3));
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             PGPSignatureSubpacketGenerator subPacketGenerator = new PGPSignatureSubpacketGenerator();
358             subPacketGenerator.setIssuerFingerprint(false, secretKey);
359             this.hashSubPackets = subPacketGenerator.generate();
360         } catch (PGPException | IOException e) {
361             throw new MojoFailureException(e);
362         }
363     }
364 
365     @Override
366     public String getKeyInfo() {
367         Iterator<String> userIds = secretKey.getPublicKey().getUserIDs();
368         if (userIds.hasNext()) {
369             return userIds.next();
370         }
371         return Hex.toHexString(secretKey.getPublicKey().getFingerprint());
372     }
373 
374     @Override
375     protected void generateSignatureForFile(File file, File signature) throws MojoExecutionException {
376         try (InputStream in = Files.newInputStream(file.toPath());
377                 OutputStream out = new CachingOutputStream(signature.toPath())) {
378             PGPSignatureGenerator sGen = new PGPSignatureGenerator(
379                     new BcPGPContentSignerBuilder(secretKey.getPublicKey().getAlgorithm(), HashAlgorithmTags.SHA512));
380             sGen.init(PGPSignature.BINARY_DOCUMENT, privateKey);
381             sGen.setHashedSubpackets(hashSubPackets);
382             int len;
383             byte[] buffer = new byte[8 * 1024];
384             while ((len = in.read(buffer)) >= 0) {
385                 sGen.update(buffer, 0, len);
386             }
387             try (BCPGOutputStream bcpgOutputStream = new BCPGOutputStream(new ArmoredOutputStream(out))) {
388                 sGen.generate().encode(bcpgOutputStream);
389             }
390         } catch (PGPException | IOException e) {
391             throw new MojoExecutionException(e);
392         }
393     }
394 }