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