View Javadoc
1   /*
2    * Copyright (C) 2018, Salesforce.
3    * and other copyright owners as documented in the project's IP log.
4    *
5    * This program and the accompanying materials are made available
6    * under the terms of the Eclipse Distribution License v1.0 which
7    * accompanies this distribution, is reproduced below, and is
8    * available at http://www.eclipse.org/org/documents/edl-v10.php
9    *
10   * All rights reserved.
11   *
12   * Redistribution and use in source and binary forms, with or
13   * without modification, are permitted provided that the following
14   * conditions are met:
15   *
16   * - Redistributions of source code must retain the above copyright
17   *   notice, this list of conditions and the following disclaimer.
18   *
19   * - Redistributions in binary form must reproduce the above
20   *   copyright notice, this list of conditions and the following
21   *   disclaimer in the documentation and/or other materials provided
22   *   with the distribution.
23   *
24   * - Neither the name of the Eclipse Foundation, Inc. nor the
25   *   names of its contributors may be used to endorse or promote
26   *   products derived from this software without specific prior
27   *   written permission.
28   *
29   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
30   * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
31   * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
32   * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
33   * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
34   * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
35   * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
36   * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
37   * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
38   * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
39   * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
40   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
41   * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
42   */
43  package org.eclipse.jgit.lib.internal;
44  
45  import static java.nio.file.Files.exists;
46  import static java.nio.file.Files.newInputStream;
47  
48  import java.io.BufferedInputStream;
49  import java.io.File;
50  import java.io.IOException;
51  import java.io.InputStream;
52  import java.net.URISyntaxException;
53  import java.nio.file.DirectoryStream;
54  import java.nio.file.Files;
55  import java.nio.file.InvalidPathException;
56  import java.nio.file.Path;
57  import java.nio.file.Paths;
58  import java.security.NoSuchAlgorithmException;
59  import java.security.NoSuchProviderException;
60  import java.text.MessageFormat;
61  import java.util.Iterator;
62  import java.util.Locale;
63  import java.util.stream.Collectors;
64  import java.util.stream.Stream;
65  
66  import org.bouncycastle.gpg.SExprParser;
67  import org.bouncycastle.gpg.keybox.BlobType;
68  import org.bouncycastle.gpg.keybox.KeyBlob;
69  import org.bouncycastle.gpg.keybox.KeyBox;
70  import org.bouncycastle.gpg.keybox.KeyInformation;
71  import org.bouncycastle.gpg.keybox.PublicKeyRingBlob;
72  import org.bouncycastle.gpg.keybox.UserID;
73  import org.bouncycastle.gpg.keybox.jcajce.JcaKeyBoxBuilder;
74  import org.bouncycastle.openpgp.PGPException;
75  import org.bouncycastle.openpgp.PGPPublicKey;
76  import org.bouncycastle.openpgp.PGPPublicKeyRing;
77  import org.bouncycastle.openpgp.PGPPublicKeyRingCollection;
78  import org.bouncycastle.openpgp.PGPSecretKey;
79  import org.bouncycastle.openpgp.PGPSecretKeyRing;
80  import org.bouncycastle.openpgp.PGPSecretKeyRingCollection;
81  import org.bouncycastle.openpgp.PGPUtil;
82  import org.bouncycastle.openpgp.operator.PBEProtectionRemoverFactory;
83  import org.bouncycastle.openpgp.operator.PGPDigestCalculatorProvider;
84  import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator;
85  import org.bouncycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder;
86  import org.bouncycastle.openpgp.operator.jcajce.JcePBEProtectionRemoverFactory;
87  import org.bouncycastle.util.encoders.Hex;
88  import org.eclipse.jgit.annotations.NonNull;
89  import org.eclipse.jgit.api.errors.CanceledException;
90  import org.eclipse.jgit.errors.UnsupportedCredentialItem;
91  import org.eclipse.jgit.internal.JGitText;
92  import org.eclipse.jgit.util.FS;
93  import org.eclipse.jgit.util.SystemReader;
94  import org.slf4j.Logger;
95  import org.slf4j.LoggerFactory;
96  
97  /**
98   * Locates GPG keys from either <code>~/.gnupg/private-keys-v1.d</code> or
99   * <code>~/.gnupg/secring.gpg</code>
100  */
101 class BouncyCastleGpgKeyLocator {
102 
103 	/** Thrown if a keybox file exists but doesn't contain an OpenPGP key. */
104 	private static class NoOpenPgpKeyException extends Exception {
105 
106 		private static final long serialVersionUID = 1L;
107 
108 	}
109 
110 	private static final Logger log = LoggerFactory
111 			.getLogger(BouncyCastleGpgKeyLocator.class);
112 
113 	private static final Path GPG_DIRECTORY = findGpgDirectory();
114 
115 	private static final Path USER_KEYBOX_PATH = GPG_DIRECTORY
116 			.resolve("pubring.kbx"); //$NON-NLS-1$
117 
118 	private static final Path USER_SECRET_KEY_DIR = GPG_DIRECTORY
119 			.resolve("private-keys-v1.d"); //$NON-NLS-1$
120 
121 	private static final Path USER_PGP_PUBRING_FILE = GPG_DIRECTORY
122 			.resolve("pubring.gpg"); //$NON-NLS-1$
123 
124 	private static final Path USER_PGP_LEGACY_SECRING_FILE = GPG_DIRECTORY
125 			.resolve("secring.gpg"); //$NON-NLS-1$
126 
127 	private final String signingKey;
128 
129 	private BouncyCastleGpgKeyPassphrasePrompt passphrasePrompt;
130 
131 	private static Path findGpgDirectory() {
132 		SystemReader system = SystemReader.getInstance();
133 		if (system.isWindows()) {
134 			// On Windows prefer %APPDATA%\gnupg if it exists, even if Cygwin is
135 			// used.
136 			String appData = system.getenv("APPDATA"); //$NON-NLS-1$
137 			if (appData != null && !appData.isEmpty()) {
138 				try {
139 					Path directory = Paths.get(appData).resolve("gnupg"); //$NON-NLS-1$
140 					if (Files.isDirectory(directory)) {
141 						return directory;
142 					}
143 				} catch (SecurityException | InvalidPathException e) {
144 					// Ignore and return the default location below.
145 				}
146 			}
147 		}
148 		// All systems, including Cygwin and even Windows if
149 		// %APPDATA%\gnupg doesn't exist: ~/.gnupg
150 		File home = FS.DETECTED.userHome();
151 		if (home == null) {
152 			// Oops. What now?
153 			home = new File(".").getAbsoluteFile(); //$NON-NLS-1$
154 		}
155 		return home.toPath().resolve(".gnupg"); //$NON-NLS-1$
156 	}
157 
158 	/**
159 	 * Create a new key locator for the specified signing key.
160 	 * <p>
161 	 * The signing key must either be a hex representation of a specific key or
162 	 * a user identity substring (eg., email address). All keys in the KeyBox
163 	 * will be looked up in the order as returned by the KeyBox. A key id will
164 	 * be searched before attempting to find a key by user id.
165 	 * </p>
166 	 *
167 	 * @param signingKey
168 	 *            the signing key to search for
169 	 * @param passphrasePrompt
170 	 *            the provider to use when asking for key passphrase
171 	 */
172 	public BouncyCastleGpgKeyLocator(String signingKey,
173 			@NonNull BouncyCastleGpgKeyPassphrasePrompt passphrasePrompt) {
174 		this.signingKey = signingKey;
175 		this.passphrasePrompt = passphrasePrompt;
176 	}
177 
178 	private PGPSecretKey attemptParseSecretKey(Path keyFile,
179 			PGPDigestCalculatorProvider calculatorProvider,
180 			PBEProtectionRemoverFactory passphraseProvider,
181 			PGPPublicKey publicKey) {
182 		try (InputStream in = newInputStream(keyFile)) {
183 			return new SExprParser(calculatorProvider).parseSecretKey(
184 					new BufferedInputStream(in), passphraseProvider, publicKey);
185 		} catch (IOException | PGPException | ClassCastException e) {
186 			if (log.isDebugEnabled())
187 				log.debug("Ignoring unreadable file '{}': {}", keyFile, //$NON-NLS-1$
188 						e.getMessage(), e);
189 			return null;
190 		}
191 	}
192 
193 	private boolean containsSigningKey(String userId) {
194 		return userId.toLowerCase(Locale.ROOT)
195 				.contains(signingKey.toLowerCase(Locale.ROOT));
196 	}
197 
198 	private PGPPublicKey findPublicKeyByKeyId(KeyBlob keyBlob)
199 			throws IOException {
200 		String keyId = signingKey.toLowerCase(Locale.ROOT);
201 		for (KeyInformation keyInfo : keyBlob.getKeyInformation()) {
202 			String fingerprint = Hex.toHexString(keyInfo.getFingerprint())
203 					.toLowerCase(Locale.ROOT);
204 			if (fingerprint.endsWith(keyId)) {
205 				return getFirstPublicKey(keyBlob);
206 			}
207 		}
208 		return null;
209 	}
210 
211 	private PGPPublicKey findPublicKeyByUserId(KeyBlob keyBlob)
212 			throws IOException {
213 		for (UserID userID : keyBlob.getUserIds()) {
214 			if (containsSigningKey(userID.getUserIDAsString())) {
215 				return getFirstPublicKey(keyBlob);
216 			}
217 		}
218 		return null;
219 	}
220 
221 	/**
222 	 * Finds a public key associated with the signing key.
223 	 *
224 	 * @param keyboxFile
225 	 *            the KeyBox file
226 	 * @return publicKey the public key (maybe <code>null</code>)
227 	 * @throws IOException
228 	 *             in case of problems reading the file
229 	 * @throws NoSuchAlgorithmException
230 	 * @throws NoSuchProviderException
231 	 * @throws NoOpenPgpKeyException
232 	 *             if the file does not contain any OpenPGP key
233 	 */
234 	private PGPPublicKey findPublicKeyInKeyBox(Path keyboxFile)
235 			throws IOException, NoSuchAlgorithmException,
236 			NoSuchProviderException, NoOpenPgpKeyException {
237 		KeyBox keyBox = readKeyBoxFile(keyboxFile);
238 		boolean hasOpenPgpKey = false;
239 		for (KeyBlob keyBlob : keyBox.getKeyBlobs()) {
240 			if (keyBlob.getType() == BlobType.OPEN_PGP_BLOB) {
241 				hasOpenPgpKey = true;
242 				PGPPublicKey key = findPublicKeyByKeyId(keyBlob);
243 				if (key != null) {
244 					return key;
245 				}
246 				key = findPublicKeyByUserId(keyBlob);
247 				if (key != null) {
248 					return key;
249 				}
250 			}
251 		}
252 		if (!hasOpenPgpKey) {
253 			throw new NoOpenPgpKeyException();
254 		}
255 		return null;
256 	}
257 
258 	/**
259 	 * If there is a private key directory containing keys, use pubring.kbx or
260 	 * pubring.gpg to find the public key; then try to find the secret key in
261 	 * the directory.
262 	 * <p>
263 	 * If there is no private key directory (or it doesn't contain any keys),
264 	 * try to find the key in secring.gpg directly.
265 	 * </p>
266 	 *
267 	 * @return the secret key
268 	 * @throws IOException
269 	 *             in case of issues reading key files
270 	 * @throws NoSuchAlgorithmException
271 	 * @throws NoSuchProviderException
272 	 * @throws PGPException
273 	 *             in case of issues finding a key, including no key found
274 	 * @throws CanceledException
275 	 * @throws URISyntaxException
276 	 * @throws UnsupportedCredentialItem
277 	 */
278 	@NonNull
279 	public BouncyCastleGpgKey findSecretKey() throws IOException,
280 			NoSuchAlgorithmException, NoSuchProviderException, PGPException,
281 			CanceledException, UnsupportedCredentialItem, URISyntaxException {
282 		BouncyCastleGpgKey key;
283 		PGPPublicKey publicKey = null;
284 		if (hasKeyFiles(USER_SECRET_KEY_DIR)) {
285 			// Use pubring.kbx or pubring.gpg to find the public key, then try
286 			// the key files in the directory. If the public key was found in
287 			// pubring.gpg also try secring.gpg to find the secret key.
288 			if (exists(USER_KEYBOX_PATH)) {
289 				try {
290 					publicKey = findPublicKeyInKeyBox(USER_KEYBOX_PATH);
291 					if (publicKey != null) {
292 						key = findSecretKeyForKeyBoxPublicKey(publicKey,
293 								USER_KEYBOX_PATH);
294 						if (key != null) {
295 							return key;
296 						}
297 						throw new PGPException(MessageFormat.format(
298 								JGitText.get().gpgNoSecretKeyForPublicKey,
299 								Long.toHexString(publicKey.getKeyID())));
300 					}
301 					throw new PGPException(MessageFormat.format(
302 							JGitText.get().gpgNoPublicKeyFound, signingKey));
303 				} catch (NoOpenPgpKeyException e) {
304 					// There are no OpenPGP keys in the keybox at all: try the
305 					// pubring.gpg, if it exists.
306 					if (log.isDebugEnabled()) {
307 						log.debug("{} does not contain any OpenPGP keys", //$NON-NLS-1$
308 								USER_KEYBOX_PATH);
309 					}
310 				}
311 			}
312 			if (exists(USER_PGP_PUBRING_FILE)) {
313 				publicKey = findPublicKeyInPubring(USER_PGP_PUBRING_FILE);
314 				if (publicKey != null) {
315 					// GPG < 2.1 may have both; the agent using the directory
316 					// and gpg using secring.gpg. GPG >= 2.1 delegates all
317 					// secret key handling to the agent and doesn't use
318 					// secring.gpg at all, even if it exists. Which means for us
319 					// we have to try both since we don't know which GPG version
320 					// the user has.
321 					key = findSecretKeyForKeyBoxPublicKey(publicKey,
322 							USER_PGP_PUBRING_FILE);
323 					if (key != null) {
324 						return key;
325 					}
326 				}
327 			}
328 			if (publicKey == null) {
329 				throw new PGPException(MessageFormat.format(
330 						JGitText.get().gpgNoPublicKeyFound, signingKey));
331 			}
332 			// We found a public key, but didn't find the secret key in the
333 			// private key directory. Go try the secring.gpg.
334 		}
335 		boolean hasSecring = false;
336 		if (exists(USER_PGP_LEGACY_SECRING_FILE)) {
337 			hasSecring = true;
338 			key = loadKeyFromSecring(USER_PGP_LEGACY_SECRING_FILE);
339 			if (key != null) {
340 				return key;
341 			}
342 		}
343 		if (publicKey != null) {
344 			throw new PGPException(MessageFormat.format(
345 					JGitText.get().gpgNoSecretKeyForPublicKey,
346 					Long.toHexString(publicKey.getKeyID())));
347 		} else if (hasSecring) {
348 			// publicKey == null: user has _only_ pubring.gpg/secring.gpg.
349 			throw new PGPException(MessageFormat.format(
350 					JGitText.get().gpgNoKeyInLegacySecring, signingKey));
351 		} else {
352 			throw new PGPException(JGitText.get().gpgNoKeyring);
353 		}
354 	}
355 
356 	private boolean hasKeyFiles(Path dir) {
357 		try (DirectoryStream<Path> contents = Files.newDirectoryStream(dir,
358 				"*.key")) { //$NON-NLS-1$
359 			return contents.iterator().hasNext();
360 		} catch (IOException e) {
361 			// Not a directory, or something else
362 			return false;
363 		}
364 	}
365 
366 	private BouncyCastleGpgKey loadKeyFromSecring(Path secring)
367 			throws IOException, PGPException {
368 		PGPSecretKey secretKey = findSecretKeyInLegacySecring(signingKey,
369 				secring);
370 
371 		if (secretKey != null) {
372 			if (!secretKey.isSigningKey()) {
373 				throw new PGPException(MessageFormat
374 						.format(JGitText.get().gpgNotASigningKey, signingKey));
375 			}
376 			return new BouncyCastleGpgKey(secretKey, secring);
377 		}
378 		return null;
379 	}
380 
381 	private BouncyCastleGpgKey findSecretKeyForKeyBoxPublicKey(
382 			PGPPublicKey publicKey, Path userKeyboxPath)
383 			throws PGPException, CanceledException, UnsupportedCredentialItem,
384 			URISyntaxException {
385 		/*
386 		 * this is somewhat brute-force but there doesn't seem to be another
387 		 * way; we have to walk all private key files we find and try to open
388 		 * them
389 		 */
390 
391 		PGPDigestCalculatorProvider calculatorProvider = new JcaPGPDigestCalculatorProviderBuilder()
392 				.build();
393 
394 		PBEProtectionRemoverFactory passphraseProvider = new JcePBEProtectionRemoverFactory(
395 				passphrasePrompt.getPassphrase(publicKey.getFingerprint(),
396 						userKeyboxPath));
397 
398 		try (Stream<Path> keyFiles = Files.walk(USER_SECRET_KEY_DIR)) {
399 			for (Path keyFile : keyFiles.filter(Files::isRegularFile)
400 					.collect(Collectors.toList())) {
401 				PGPSecretKey secretKey = attemptParseSecretKey(keyFile,
402 						calculatorProvider, passphraseProvider, publicKey);
403 				if (secretKey != null) {
404 					if (!secretKey.isSigningKey()) {
405 						throw new PGPException(MessageFormat.format(
406 								JGitText.get().gpgNotASigningKey, signingKey));
407 					}
408 					return new BouncyCastleGpgKey(secretKey, userKeyboxPath);
409 				}
410 			}
411 
412 			passphrasePrompt.clear();
413 			return null;
414 		} catch (RuntimeException e) {
415 			passphrasePrompt.clear();
416 			throw e;
417 		} catch (IOException e) {
418 			passphrasePrompt.clear();
419 			throw new PGPException(MessageFormat.format(
420 					JGitText.get().gpgFailedToParseSecretKey,
421 					USER_SECRET_KEY_DIR.toAbsolutePath()), e);
422 		}
423 	}
424 
425 	/**
426 	 * Return the first suitable key for signing in the key ring collection. For
427 	 * this case we only expect there to be one key available for signing.
428 	 * </p>
429 	 *
430 	 * @param signingkey
431 	 * @param secringFile
432 	 *
433 	 * @return the first suitable PGP secret key found for signing
434 	 * @throws IOException
435 	 *             on I/O related errors
436 	 * @throws PGPException
437 	 *             on BouncyCastle errors
438 	 */
439 	private PGPSecretKey findSecretKeyInLegacySecring(String signingkey,
440 			Path secringFile) throws IOException, PGPException {
441 
442 		try (InputStream in = newInputStream(secringFile)) {
443 			PGPSecretKeyRingCollection pgpSec = new PGPSecretKeyRingCollection(
444 					PGPUtil.getDecoderStream(new BufferedInputStream(in)),
445 					new JcaKeyFingerprintCalculator());
446 
447 			String keyId = signingkey.toLowerCase(Locale.ROOT);
448 			Iterator<PGPSecretKeyRing> keyrings = pgpSec.getKeyRings();
449 			while (keyrings.hasNext()) {
450 				PGPSecretKeyRing keyRing = keyrings.next();
451 				Iterator<PGPSecretKey> keys = keyRing.getSecretKeys();
452 				while (keys.hasNext()) {
453 					PGPSecretKey key = keys.next();
454 					// try key id
455 					String fingerprint = Hex
456 							.toHexString(key.getPublicKey().getFingerprint())
457 							.toLowerCase(Locale.ROOT);
458 					if (fingerprint.endsWith(keyId)) {
459 						return key;
460 					}
461 					// try user id
462 					Iterator<String> userIDs = key.getUserIDs();
463 					while (userIDs.hasNext()) {
464 						String userId = userIDs.next();
465 						if (containsSigningKey(userId)) {
466 							return key;
467 						}
468 					}
469 				}
470 			}
471 		}
472 		return null;
473 	}
474 
475 	/**
476 	 * Return the first public key matching the key id ({@link #signingKey}.
477 	 *
478 	 * @param pubringFile
479 	 *
480 	 * @return the PGP public key, or {@code null} if none found
481 	 * @throws IOException
482 	 *             on I/O related errors
483 	 * @throws PGPException
484 	 *             on BouncyCastle errors
485 	 */
486 	private PGPPublicKey findPublicKeyInPubring(Path pubringFile)
487 			throws IOException, PGPException {
488 		try (InputStream in = newInputStream(pubringFile)) {
489 			PGPPublicKeyRingCollection pgpPub = new PGPPublicKeyRingCollection(
490 					new BufferedInputStream(in),
491 					new JcaKeyFingerprintCalculator());
492 
493 			String keyId = signingKey.toLowerCase(Locale.ROOT);
494 			Iterator<PGPPublicKeyRing> keyrings = pgpPub.getKeyRings();
495 			while (keyrings.hasNext()) {
496 				PGPPublicKeyRing keyRing = keyrings.next();
497 				Iterator<PGPPublicKey> keys = keyRing.getPublicKeys();
498 				while (keys.hasNext()) {
499 					PGPPublicKey key = keys.next();
500 					// try key id
501 					String fingerprint = Hex.toHexString(key.getFingerprint())
502 							.toLowerCase(Locale.ROOT);
503 					if (fingerprint.endsWith(keyId)) {
504 						return key;
505 					}
506 					// try user id
507 					Iterator<String> userIDs = key.getUserIDs();
508 					while (userIDs.hasNext()) {
509 						String userId = userIDs.next();
510 						if (containsSigningKey(userId)) {
511 							return key;
512 						}
513 					}
514 				}
515 			}
516 		}
517 		return null;
518 	}
519 
520 	private PGPPublicKey getFirstPublicKey(KeyBlob keyBlob) throws IOException {
521 		return ((PublicKeyRingBlob) keyBlob).getPGPPublicKeyRing()
522 				.getPublicKey();
523 	}
524 
525 	private KeyBox readKeyBoxFile(Path keyboxFile) throws IOException,
526 			NoSuchAlgorithmException, NoSuchProviderException,
527 			NoOpenPgpKeyException {
528 		if (keyboxFile.toFile().length() == 0) {
529 			throw new NoOpenPgpKeyException();
530 		}
531 		KeyBox keyBox;
532 		try (InputStream in = new BufferedInputStream(
533 				newInputStream(keyboxFile))) {
534 			keyBox = new JcaKeyBoxBuilder().build(in);
535 		}
536 		return keyBox;
537 	}
538 }