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.Files;
54  import java.nio.file.InvalidPathException;
55  import java.nio.file.Path;
56  import java.nio.file.Paths;
57  import java.security.NoSuchAlgorithmException;
58  import java.security.NoSuchProviderException;
59  import java.text.MessageFormat;
60  import java.util.Iterator;
61  import java.util.Locale;
62  import java.util.stream.Collectors;
63  import java.util.stream.Stream;
64  
65  import org.bouncycastle.gpg.SExprParser;
66  import org.bouncycastle.gpg.keybox.BlobType;
67  import org.bouncycastle.gpg.keybox.KeyBlob;
68  import org.bouncycastle.gpg.keybox.KeyBox;
69  import org.bouncycastle.gpg.keybox.KeyInformation;
70  import org.bouncycastle.gpg.keybox.PublicKeyRingBlob;
71  import org.bouncycastle.gpg.keybox.UserID;
72  import org.bouncycastle.gpg.keybox.jcajce.JcaKeyBoxBuilder;
73  import org.bouncycastle.openpgp.PGPException;
74  import org.bouncycastle.openpgp.PGPPublicKey;
75  import org.bouncycastle.openpgp.PGPSecretKey;
76  import org.bouncycastle.openpgp.PGPSecretKeyRing;
77  import org.bouncycastle.openpgp.PGPSecretKeyRingCollection;
78  import org.bouncycastle.openpgp.PGPUtil;
79  import org.bouncycastle.openpgp.operator.PBEProtectionRemoverFactory;
80  import org.bouncycastle.openpgp.operator.PGPDigestCalculatorProvider;
81  import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator;
82  import org.bouncycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder;
83  import org.bouncycastle.openpgp.operator.jcajce.JcePBEProtectionRemoverFactory;
84  import org.bouncycastle.util.encoders.Hex;
85  import org.eclipse.jgit.annotations.NonNull;
86  import org.eclipse.jgit.api.errors.CanceledException;
87  import org.eclipse.jgit.errors.UnsupportedCredentialItem;
88  import org.eclipse.jgit.internal.JGitText;
89  import org.eclipse.jgit.util.FS;
90  import org.eclipse.jgit.util.SystemReader;
91  import org.slf4j.Logger;
92  import org.slf4j.LoggerFactory;
93  
94  /**
95   * Locates GPG keys from either <code>~/.gnupg/private-keys-v1.d</code> or
96   * <code>~/.gnupg/secring.gpg</code>
97   */
98  class BouncyCastleGpgKeyLocator {
99  
100 	private static final Logger log = LoggerFactory
101 			.getLogger(BouncyCastleGpgKeyLocator.class);
102 
103 	private static final Path GPG_DIRECTORY = findGpgDirectory();
104 
105 	private static final Path USER_KEYBOX_PATH = GPG_DIRECTORY
106 			.resolve("pubring.kbx"); //$NON-NLS-1$
107 
108 	private static final Path USER_SECRET_KEY_DIR = GPG_DIRECTORY
109 			.resolve("private-keys-v1.d"); //$NON-NLS-1$
110 
111 	private static final Path USER_PGP_LEGACY_SECRING_FILE = GPG_DIRECTORY
112 			.resolve("secring.gpg"); //$NON-NLS-1$
113 
114 	private final String signingKey;
115 
116 	private BouncyCastleGpgKeyPassphrasePrompt passphrasePrompt;
117 
118 	private static Path findGpgDirectory() {
119 		SystemReader system = SystemReader.getInstance();
120 		if (system.isWindows()) {
121 			// On Windows prefer %APPDATA%\gnupg if it exists, even if Cygwin is
122 			// used.
123 			String appData = system.getenv("APPDATA"); //$NON-NLS-1$
124 			if (appData != null && !appData.isEmpty()) {
125 				try {
126 					Path directory = Paths.get(appData).resolve("gnupg"); //$NON-NLS-1$
127 					if (Files.isDirectory(directory)) {
128 						return directory;
129 					}
130 				} catch (SecurityException | InvalidPathException e) {
131 					// Ignore and return the default location below.
132 				}
133 			}
134 		}
135 		// All systems, including Cygwin and even Windows if
136 		// %APPDATA%\gnupg doesn't exist: ~/.gnupg
137 		File home = FS.DETECTED.userHome();
138 		if (home == null) {
139 			// Oops. What now?
140 			home = new File(".").getAbsoluteFile(); //$NON-NLS-1$
141 		}
142 		return home.toPath().resolve(".gnupg"); //$NON-NLS-1$
143 	}
144 
145 	/**
146 	 * Create a new key locator for the specified signing key.
147 	 * <p>
148 	 * The signing key must either be a hex representation of a specific key or
149 	 * a user identity substring (eg., email address). All keys in the KeyBox
150 	 * will be looked up in the order as returned by the KeyBox. A key id will
151 	 * be searched before attempting to find a key by user id.
152 	 * </p>
153 	 *
154 	 * @param signingKey
155 	 *            the signing key to search for
156 	 * @param passphrasePrompt
157 	 *            the provider to use when asking for key passphrase
158 	 */
159 	public BouncyCastleGpgKeyLocator(String signingKey,
160 			@NonNull BouncyCastleGpgKeyPassphrasePrompt passphrasePrompt) {
161 		this.signingKey = signingKey;
162 		this.passphrasePrompt = passphrasePrompt;
163 	}
164 
165 	private PGPSecretKey attemptParseSecretKey(Path keyFile,
166 			PGPDigestCalculatorProvider calculatorProvider,
167 			PBEProtectionRemoverFactory passphraseProvider,
168 			PGPPublicKey publicKey) {
169 		try (InputStream in = newInputStream(keyFile)) {
170 			return new SExprParser(calculatorProvider).parseSecretKey(
171 					new BufferedInputStream(in), passphraseProvider, publicKey);
172 		} catch (IOException | PGPException | ClassCastException e) {
173 			if (log.isDebugEnabled())
174 				log.debug("Ignoring unreadable file '{}': {}", keyFile, //$NON-NLS-1$
175 						e.getMessage(), e);
176 			return null;
177 		}
178 	}
179 
180 	private boolean containsSigningKey(String userId) {
181 		return userId.toLowerCase(Locale.ROOT)
182 				.contains(signingKey.toLowerCase(Locale.ROOT));
183 	}
184 
185 	private PGPPublicKey findPublicKeyByKeyId(KeyBlob keyBlob)
186 			throws IOException {
187 		String keyId = signingKey.toLowerCase(Locale.ROOT);
188 		for (KeyInformation keyInfo : keyBlob.getKeyInformation()) {
189 			String fingerprint = Hex.toHexString(keyInfo.getFingerprint())
190 					.toLowerCase(Locale.ROOT);
191 			if (fingerprint.endsWith(keyId)) {
192 				return getFirstPublicKey(keyBlob);
193 			}
194 		}
195 		return null;
196 	}
197 
198 	private PGPPublicKey findPublicKeyByUserId(KeyBlob keyBlob)
199 			throws IOException {
200 		for (UserID userID : keyBlob.getUserIds()) {
201 			if (containsSigningKey(userID.getUserIDAsString())) {
202 				return getFirstPublicKey(keyBlob);
203 			}
204 		}
205 		return null;
206 	}
207 
208 	/**
209 	 * Finds a public key associated with the signing key.
210 	 *
211 	 * @param keyboxFile
212 	 *            the KeyBox file
213 	 * @return publicKey the public key (maybe <code>null</code>)
214 	 * @throws IOException
215 	 *             in case of problems reading the file
216 	 * @throws NoSuchAlgorithmException
217 	 * @throws NoSuchProviderException
218 	 */
219 	private PGPPublicKey findPublicKeyInKeyBox(Path keyboxFile)
220 			throws IOException, NoSuchAlgorithmException,
221 			NoSuchProviderException {
222 		KeyBox keyBox = readKeyBoxFile(keyboxFile);
223 		for (KeyBlob keyBlob : keyBox.getKeyBlobs()) {
224 			if (keyBlob.getType() == BlobType.OPEN_PGP_BLOB) {
225 				PGPPublicKey key = findPublicKeyByKeyId(keyBlob);
226 				if (key != null) {
227 					return key;
228 				}
229 				key = findPublicKeyByUserId(keyBlob);
230 				if (key != null) {
231 					return key;
232 				}
233 			}
234 		}
235 		return null;
236 	}
237 
238 	/**
239 	 * Use pubring.kbx when available, if not fallback to secring.gpg or secret
240 	 * key path provided to parse and return secret key
241 	 *
242 	 * @return the secret key
243 	 * @throws IOException
244 	 *             in case of issues reading key files
245 	 * @throws NoSuchAlgorithmException
246 	 * @throws NoSuchProviderException
247 	 * @throws PGPException
248 	 *             in case of issues finding a key
249 	 * @throws CanceledException
250 	 * @throws URISyntaxException
251 	 * @throws UnsupportedCredentialItem
252 	 */
253 	public BouncyCastleGpgKey findSecretKey() throws IOException,
254 			NoSuchAlgorithmException, NoSuchProviderException, PGPException,
255 			CanceledException, UnsupportedCredentialItem, URISyntaxException {
256 		if (exists(USER_KEYBOX_PATH)) {
257 			PGPPublicKey publicKey = //
258 					findPublicKeyInKeyBox(USER_KEYBOX_PATH);
259 
260 			if (publicKey != null) {
261 				return findSecretKeyForKeyBoxPublicKey(publicKey,
262 						USER_KEYBOX_PATH);
263 			}
264 
265 			throw new PGPException(MessageFormat
266 					.format(JGitText.get().gpgNoPublicKeyFound, signingKey));
267 		} else if (exists(USER_PGP_LEGACY_SECRING_FILE)) {
268 			PGPSecretKey secretKey = findSecretKeyInLegacySecring(signingKey,
269 					USER_PGP_LEGACY_SECRING_FILE);
270 
271 			if (secretKey != null) {
272 				if (!secretKey.isSigningKey()) {
273 					throw new PGPException(MessageFormat.format(
274 							JGitText.get().gpgNotASigningKey, signingKey));
275 				}
276 				return new BouncyCastleGpgKey(secretKey, USER_PGP_LEGACY_SECRING_FILE);
277 			}
278 
279 			throw new PGPException(MessageFormat.format(
280 					JGitText.get().gpgNoKeyInLegacySecring, signingKey));
281 		}
282 
283 		throw new PGPException(JGitText.get().gpgNoKeyring);
284 	}
285 
286 	private BouncyCastleGpgKey findSecretKeyForKeyBoxPublicKey(
287 			PGPPublicKey publicKey, Path userKeyboxPath)
288 			throws PGPException, CanceledException, UnsupportedCredentialItem,
289 			URISyntaxException {
290 		/*
291 		 * this is somewhat brute-force but there doesn't seem to be another
292 		 * way; we have to walk all private key files we find and try to open
293 		 * them
294 		 */
295 
296 		PGPDigestCalculatorProvider calculatorProvider = new JcaPGPDigestCalculatorProviderBuilder()
297 				.build();
298 
299 		PBEProtectionRemoverFactory passphraseProvider = new JcePBEProtectionRemoverFactory(
300 				passphrasePrompt.getPassphrase(publicKey.getFingerprint(),
301 						userKeyboxPath));
302 
303 		try (Stream<Path> keyFiles = Files.walk(USER_SECRET_KEY_DIR)) {
304 			for (Path keyFile : keyFiles.filter(Files::isRegularFile)
305 					.collect(Collectors.toList())) {
306 				PGPSecretKey secretKey = attemptParseSecretKey(keyFile,
307 						calculatorProvider, passphraseProvider, publicKey);
308 				if (secretKey != null) {
309 					if (!secretKey.isSigningKey()) {
310 						throw new PGPException(MessageFormat.format(
311 								JGitText.get().gpgNotASigningKey, signingKey));
312 					}
313 					return new BouncyCastleGpgKey(secretKey, userKeyboxPath);
314 				}
315 			}
316 
317 			passphrasePrompt.clear();
318 			throw new PGPException(MessageFormat.format(
319 					JGitText.get().gpgNoSecretKeyForPublicKey,
320 					Long.toHexString(publicKey.getKeyID())));
321 		} catch (RuntimeException e) {
322 			passphrasePrompt.clear();
323 			throw e;
324 		} catch (IOException e) {
325 			passphrasePrompt.clear();
326 			throw new PGPException(MessageFormat.format(
327 					JGitText.get().gpgFailedToParseSecretKey,
328 					USER_SECRET_KEY_DIR.toAbsolutePath()), e);
329 		}
330 	}
331 
332 	/**
333 	 * Return the first suitable key for signing in the key ring collection. For
334 	 * this case we only expect there to be one key available for signing.
335 	 * </p>
336 	 *
337 	 * @param signingkey
338 	 * @param secringFile
339 	 *
340 	 * @return the first suitable PGP secret key found for signing
341 	 * @throws IOException
342 	 *             on I/O related errors
343 	 * @throws PGPException
344 	 *             on BouncyCastle errors
345 	 */
346 	private PGPSecretKey findSecretKeyInLegacySecring(String signingkey,
347 			Path secringFile) throws IOException, PGPException {
348 
349 		try (InputStream in = newInputStream(secringFile)) {
350 			PGPSecretKeyRingCollection pgpSec = new PGPSecretKeyRingCollection(
351 					PGPUtil.getDecoderStream(new BufferedInputStream(in)),
352 					new JcaKeyFingerprintCalculator());
353 
354 			String keyId = signingkey.toLowerCase(Locale.ROOT);
355 			Iterator<PGPSecretKeyRing> keyrings = pgpSec.getKeyRings();
356 			while (keyrings.hasNext()) {
357 				PGPSecretKeyRing keyRing = keyrings.next();
358 				Iterator<PGPSecretKey> keys = keyRing.getSecretKeys();
359 				while (keys.hasNext()) {
360 					PGPSecretKey key = keys.next();
361 					// try key id
362 					String fingerprint = Hex
363 							.toHexString(key.getPublicKey().getFingerprint())
364 							.toLowerCase(Locale.ROOT);
365 					if (fingerprint.endsWith(keyId)) {
366 						return key;
367 					}
368 					// try user id
369 					Iterator<String> userIDs = key.getUserIDs();
370 					while (userIDs.hasNext()) {
371 						String userId = userIDs.next();
372 						if (containsSigningKey(userId)) {
373 							return key;
374 						}
375 					}
376 				}
377 			}
378 		}
379 		return null;
380 	}
381 
382 	private PGPPublicKey getFirstPublicKey(KeyBlob keyBlob) throws IOException {
383 		return ((PublicKeyRingBlob) keyBlob).getPGPPublicKeyRing()
384 				.getPublicKey();
385 	}
386 
387 	private KeyBox readKeyBoxFile(Path keyboxFile) throws IOException,
388 			NoSuchAlgorithmException, NoSuchProviderException {
389 		KeyBox keyBox;
390 		try (InputStream in = new BufferedInputStream(
391 				newInputStream(keyboxFile))) {
392 			keyBox = new JcaKeyBoxBuilder().build(in);
393 		}
394 		return keyBox;
395 	}
396 }