BouncyCastleGpgKeyLocator.java

/*
 * Copyright (C) 2018, Salesforce. and others
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Distribution License v. 1.0 which is available at
 * https://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 */
package org.eclipse.jgit.lib.internal;

import static java.nio.file.Files.exists;
import static java.nio.file.Files.newInputStream;

import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URISyntaxException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.text.MessageFormat;
import java.util.Iterator;
import java.util.Locale;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.bouncycastle.gpg.SExprParser;
import org.bouncycastle.gpg.keybox.BlobType;
import org.bouncycastle.gpg.keybox.KeyBlob;
import org.bouncycastle.gpg.keybox.KeyBox;
import org.bouncycastle.gpg.keybox.KeyInformation;
import org.bouncycastle.gpg.keybox.PublicKeyRingBlob;
import org.bouncycastle.gpg.keybox.UserID;
import org.bouncycastle.gpg.keybox.jcajce.JcaKeyBoxBuilder;
import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.PGPKeyFlags;
import org.bouncycastle.openpgp.PGPPublicKey;
import org.bouncycastle.openpgp.PGPPublicKeyRing;
import org.bouncycastle.openpgp.PGPPublicKeyRingCollection;
import org.bouncycastle.openpgp.PGPSecretKey;
import org.bouncycastle.openpgp.PGPSecretKeyRing;
import org.bouncycastle.openpgp.PGPSecretKeyRingCollection;
import org.bouncycastle.openpgp.PGPSignature;
import org.bouncycastle.openpgp.PGPUtil;
import org.bouncycastle.openpgp.operator.PBEProtectionRemoverFactory;
import org.bouncycastle.openpgp.operator.PGPDigestCalculatorProvider;
import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator;
import org.bouncycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder;
import org.bouncycastle.openpgp.operator.jcajce.JcePBEProtectionRemoverFactory;
import org.bouncycastle.util.encoders.Hex;
import org.eclipse.jgit.annotations.NonNull;
import org.eclipse.jgit.api.errors.CanceledException;
import org.eclipse.jgit.errors.UnsupportedCredentialItem;
import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.util.FS;
import org.eclipse.jgit.util.StringUtils;
import org.eclipse.jgit.util.SystemReader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Locates GPG keys from either <code>~/.gnupg/private-keys-v1.d</code> or
 * <code>~/.gnupg/secring.gpg</code>
 */
class BouncyCastleGpgKeyLocator {

	/** Thrown if a keybox file exists but doesn't contain an OpenPGP key. */
	private static class NoOpenPgpKeyException extends Exception {

		private static final long serialVersionUID = 1L;

	}

	private static final Logger log = LoggerFactory
			.getLogger(BouncyCastleGpgKeyLocator.class);

	private static final Path GPG_DIRECTORY = findGpgDirectory();

	private static final Path USER_KEYBOX_PATH = GPG_DIRECTORY
			.resolve("pubring.kbx"); //$NON-NLS-1$

	private static final Path USER_SECRET_KEY_DIR = GPG_DIRECTORY
			.resolve("private-keys-v1.d"); //$NON-NLS-1$

	private static final Path USER_PGP_PUBRING_FILE = GPG_DIRECTORY
			.resolve("pubring.gpg"); //$NON-NLS-1$

	private static final Path USER_PGP_LEGACY_SECRING_FILE = GPG_DIRECTORY
			.resolve("secring.gpg"); //$NON-NLS-1$

	private final String signingKey;

	private BouncyCastleGpgKeyPassphrasePrompt passphrasePrompt;

	private static Path findGpgDirectory() {
		SystemReader system = SystemReader.getInstance();
		if (system.isWindows()) {
			// On Windows prefer %APPDATA%\gnupg if it exists, even if Cygwin is
			// used.
			String appData = system.getenv("APPDATA"); //$NON-NLS-1$
			if (appData != null && !appData.isEmpty()) {
				try {
					Path directory = Paths.get(appData).resolve("gnupg"); //$NON-NLS-1$
					if (Files.isDirectory(directory)) {
						return directory;
					}
				} catch (SecurityException | InvalidPathException e) {
					// Ignore and return the default location below.
				}
			}
		}
		// All systems, including Cygwin and even Windows if
		// %APPDATA%\gnupg doesn't exist: ~/.gnupg
		File home = FS.DETECTED.userHome();
		if (home == null) {
			// Oops. What now?
			home = new File(".").getAbsoluteFile(); //$NON-NLS-1$
		}
		return home.toPath().resolve(".gnupg"); //$NON-NLS-1$
	}

	/**
	 * Create a new key locator for the specified signing key.
	 * <p>
	 * The signing key must either be a hex representation of a specific key or
	 * a user identity substring (eg., email address). All keys in the KeyBox
	 * will be looked up in the order as returned by the KeyBox. A key id will
	 * be searched before attempting to find a key by user id.
	 * </p>
	 *
	 * @param signingKey
	 *            the signing key to search for
	 * @param passphrasePrompt
	 *            the provider to use when asking for key passphrase
	 */
	public BouncyCastleGpgKeyLocator(String signingKey,
			@NonNull BouncyCastleGpgKeyPassphrasePrompt passphrasePrompt) {
		this.signingKey = signingKey;
		this.passphrasePrompt = passphrasePrompt;
	}

	private PGPSecretKey attemptParseSecretKey(Path keyFile,
			PGPDigestCalculatorProvider calculatorProvider,
			PBEProtectionRemoverFactory passphraseProvider,
			PGPPublicKey publicKey) {
		try (InputStream in = newInputStream(keyFile)) {
			return new SExprParser(calculatorProvider).parseSecretKey(
					new BufferedInputStream(in), passphraseProvider, publicKey);
		} catch (IOException | PGPException | ClassCastException e) {
			if (log.isDebugEnabled())
				log.debug("Ignoring unreadable file '{}': {}", keyFile, //$NON-NLS-1$
						e.getMessage(), e);
			return null;
		}
	}

	/**
	 * Checks whether a given OpenPGP {@code userId} matches a given
	 * {@code signingKeySpec}, which is supposed to have one of the formats
	 * defined by GPG.
	 * <p>
	 * Not all formats are supported; only formats starting with '=', '&lt;',
	 * '@', and '*' are handled. Any other format results in a case-insensitive
	 * substring match.
	 * </p>
	 *
	 * @param userId
	 *            of a key
	 * @param signingKeySpec
	 *            GPG key identification
	 * @return whether the {@code userId} matches
	 * @see <a href=
	 *      "https://www.gnupg.org/documentation/manuals/gnupg/Specify-a-User-ID.html">GPG
	 *      Documentation: How to Specify a User ID</a>
	 */
	static boolean containsSigningKey(String userId, String signingKeySpec) {
		if (StringUtils.isEmptyOrNull(userId)
				|| StringUtils.isEmptyOrNull(signingKeySpec)) {
			return false;
		}
		String toMatch = signingKeySpec;
		if (toMatch.startsWith("0x") && toMatch.trim().length() > 2) { //$NON-NLS-1$
			return false; // Explicit fingerprint
		}
		int command = toMatch.charAt(0);
		switch (command) {
		case '=':
		case '<':
		case '@':
		case '*':
			toMatch = toMatch.substring(1);
			if (toMatch.isEmpty()) {
				return false;
			}
			break;
		default:
			break;
		}
		switch (command) {
		case '=':
			return userId.equals(toMatch);
		case '<': {
			int begin = userId.indexOf('<');
			int end = userId.indexOf('>', begin + 1);
			int stop = toMatch.indexOf('>');
			return begin >= 0 && end > begin + 1 && stop > 0
					&& userId.substring(begin + 1, end)
							.equals(toMatch.substring(0, stop));
		}
		case '@': {
			int begin = userId.indexOf('<');
			int end = userId.indexOf('>', begin + 1);
			return begin >= 0 && end > begin + 1
					&& userId.substring(begin + 1, end).contains(toMatch);
		}
		default:
			if (toMatch.trim().isEmpty()) {
				return false;
			}
			return userId.toLowerCase(Locale.ROOT)
					.contains(toMatch.toLowerCase(Locale.ROOT));
		}
	}

	private String toFingerprint(String keyId) {
		if (keyId.startsWith("0x")) { //$NON-NLS-1$
			return keyId.substring(2);
		}
		return keyId;
	}

	private PGPPublicKey findPublicKeyByKeyId(KeyBlob keyBlob)
			throws IOException {
		String keyId = toFingerprint(signingKey).toLowerCase(Locale.ROOT);
		if (keyId.isEmpty()) {
			return null;
		}
		for (KeyInformation keyInfo : keyBlob.getKeyInformation()) {
			String fingerprint = Hex.toHexString(keyInfo.getFingerprint())
					.toLowerCase(Locale.ROOT);
			if (fingerprint.endsWith(keyId)) {
				return getPublicKey(keyBlob, keyInfo.getFingerprint());
			}
		}
		return null;
	}

	private PGPPublicKey findPublicKeyByUserId(KeyBlob keyBlob)
			throws IOException {
		for (UserID userID : keyBlob.getUserIds()) {
			if (containsSigningKey(userID.getUserIDAsString(), signingKey)) {
				return getSigningPublicKey(keyBlob);
			}
		}
		return null;
	}

	/**
	 * Finds a public key associated with the signing key.
	 *
	 * @param keyboxFile
	 *            the KeyBox file
	 * @return publicKey the public key (maybe <code>null</code>)
	 * @throws IOException
	 *             in case of problems reading the file
	 * @throws NoSuchAlgorithmException
	 * @throws NoSuchProviderException
	 * @throws NoOpenPgpKeyException
	 *             if the file does not contain any OpenPGP key
	 */
	private PGPPublicKey findPublicKeyInKeyBox(Path keyboxFile)
			throws IOException, NoSuchAlgorithmException,
			NoSuchProviderException, NoOpenPgpKeyException {
		KeyBox keyBox = readKeyBoxFile(keyboxFile);
		boolean hasOpenPgpKey = false;
		for (KeyBlob keyBlob : keyBox.getKeyBlobs()) {
			if (keyBlob.getType() == BlobType.OPEN_PGP_BLOB) {
				hasOpenPgpKey = true;
				PGPPublicKey key = findPublicKeyByKeyId(keyBlob);
				if (key != null) {
					return key;
				}
				key = findPublicKeyByUserId(keyBlob);
				if (key != null) {
					return key;
				}
			}
		}
		if (!hasOpenPgpKey) {
			throw new NoOpenPgpKeyException();
		}
		return null;
	}

	/**
	 * If there is a private key directory containing keys, use pubring.kbx or
	 * pubring.gpg to find the public key; then try to find the secret key in
	 * the directory.
	 * <p>
	 * If there is no private key directory (or it doesn't contain any keys),
	 * try to find the key in secring.gpg directly.
	 * </p>
	 *
	 * @return the secret key
	 * @throws IOException
	 *             in case of issues reading key files
	 * @throws NoSuchAlgorithmException
	 * @throws NoSuchProviderException
	 * @throws PGPException
	 *             in case of issues finding a key, including no key found
	 * @throws CanceledException
	 * @throws URISyntaxException
	 * @throws UnsupportedCredentialItem
	 */
	@NonNull
	public BouncyCastleGpgKey findSecretKey() throws IOException,
			NoSuchAlgorithmException, NoSuchProviderException, PGPException,
			CanceledException, UnsupportedCredentialItem, URISyntaxException {
		BouncyCastleGpgKey key;
		PGPPublicKey publicKey = null;
		if (hasKeyFiles(USER_SECRET_KEY_DIR)) {
			// Use pubring.kbx or pubring.gpg to find the public key, then try
			// the key files in the directory. If the public key was found in
			// pubring.gpg also try secring.gpg to find the secret key.
			if (exists(USER_KEYBOX_PATH)) {
				try {
					publicKey = findPublicKeyInKeyBox(USER_KEYBOX_PATH);
					if (publicKey != null) {
						key = findSecretKeyForKeyBoxPublicKey(publicKey,
								USER_KEYBOX_PATH);
						if (key != null) {
							return key;
						}
						throw new PGPException(MessageFormat.format(
								JGitText.get().gpgNoSecretKeyForPublicKey,
								Long.toHexString(publicKey.getKeyID())));
					}
					throw new PGPException(MessageFormat.format(
							JGitText.get().gpgNoPublicKeyFound, signingKey));
				} catch (NoOpenPgpKeyException e) {
					// There are no OpenPGP keys in the keybox at all: try the
					// pubring.gpg, if it exists.
					if (log.isDebugEnabled()) {
						log.debug("{} does not contain any OpenPGP keys", //$NON-NLS-1$
								USER_KEYBOX_PATH);
					}
				}
			}
			if (exists(USER_PGP_PUBRING_FILE)) {
				publicKey = findPublicKeyInPubring(USER_PGP_PUBRING_FILE);
				if (publicKey != null) {
					// GPG < 2.1 may have both; the agent using the directory
					// and gpg using secring.gpg. GPG >= 2.1 delegates all
					// secret key handling to the agent and doesn't use
					// secring.gpg at all, even if it exists. Which means for us
					// we have to try both since we don't know which GPG version
					// the user has.
					key = findSecretKeyForKeyBoxPublicKey(publicKey,
							USER_PGP_PUBRING_FILE);
					if (key != null) {
						return key;
					}
				}
			}
			if (publicKey == null) {
				throw new PGPException(MessageFormat.format(
						JGitText.get().gpgNoPublicKeyFound, signingKey));
			}
			// We found a public key, but didn't find the secret key in the
			// private key directory. Go try the secring.gpg.
		}
		boolean hasSecring = false;
		if (exists(USER_PGP_LEGACY_SECRING_FILE)) {
			hasSecring = true;
			key = loadKeyFromSecring(USER_PGP_LEGACY_SECRING_FILE);
			if (key != null) {
				return key;
			}
		}
		if (publicKey != null) {
			throw new PGPException(MessageFormat.format(
					JGitText.get().gpgNoSecretKeyForPublicKey,
					Long.toHexString(publicKey.getKeyID())));
		} else if (hasSecring) {
			// publicKey == null: user has _only_ pubring.gpg/secring.gpg.
			throw new PGPException(MessageFormat.format(
					JGitText.get().gpgNoKeyInLegacySecring, signingKey));
		} else {
			throw new PGPException(JGitText.get().gpgNoKeyring);
		}
	}

	private boolean hasKeyFiles(Path dir) {
		try (DirectoryStream<Path> contents = Files.newDirectoryStream(dir,
				"*.key")) { //$NON-NLS-1$
			return contents.iterator().hasNext();
		} catch (IOException e) {
			// Not a directory, or something else
			return false;
		}
	}

	private BouncyCastleGpgKey loadKeyFromSecring(Path secring)
			throws IOException, PGPException {
		PGPSecretKey secretKey = findSecretKeyInLegacySecring(signingKey,
				secring);

		if (secretKey != null) {
			if (!secretKey.isSigningKey()) {
				throw new PGPException(MessageFormat
						.format(JGitText.get().gpgNotASigningKey, signingKey));
			}
			return new BouncyCastleGpgKey(secretKey, secring);
		}
		return null;
	}

	private BouncyCastleGpgKey findSecretKeyForKeyBoxPublicKey(
			PGPPublicKey publicKey, Path userKeyboxPath)
			throws PGPException, CanceledException, UnsupportedCredentialItem,
			URISyntaxException {
		/*
		 * this is somewhat brute-force but there doesn't seem to be another
		 * way; we have to walk all private key files we find and try to open
		 * them
		 */

		PGPDigestCalculatorProvider calculatorProvider = new JcaPGPDigestCalculatorProviderBuilder()
				.build();

		PBEProtectionRemoverFactory passphraseProvider = new JcePBEProtectionRemoverFactory(
				passphrasePrompt.getPassphrase(publicKey.getFingerprint(),
						userKeyboxPath));

		try (Stream<Path> keyFiles = Files.walk(USER_SECRET_KEY_DIR)) {
			for (Path keyFile : keyFiles.filter(Files::isRegularFile)
					.collect(Collectors.toList())) {
				PGPSecretKey secretKey = attemptParseSecretKey(keyFile,
						calculatorProvider, passphraseProvider, publicKey);
				if (secretKey != null) {
					if (!secretKey.isSigningKey()) {
						throw new PGPException(MessageFormat.format(
								JGitText.get().gpgNotASigningKey, signingKey));
					}
					return new BouncyCastleGpgKey(secretKey, userKeyboxPath);
				}
			}

			passphrasePrompt.clear();
			return null;
		} catch (RuntimeException e) {
			passphrasePrompt.clear();
			throw e;
		} catch (IOException e) {
			passphrasePrompt.clear();
			throw new PGPException(MessageFormat.format(
					JGitText.get().gpgFailedToParseSecretKey,
					USER_SECRET_KEY_DIR.toAbsolutePath()), e);
		}
	}

	/**
	 * Return the first suitable key for signing in the key ring collection. For
	 * this case we only expect there to be one key available for signing.
	 * </p>
	 *
	 * @param signingkey
	 * @param secringFile
	 *
	 * @return the first suitable PGP secret key found for signing
	 * @throws IOException
	 *             on I/O related errors
	 * @throws PGPException
	 *             on BouncyCastle errors
	 */
	private PGPSecretKey findSecretKeyInLegacySecring(String signingkey,
			Path secringFile) throws IOException, PGPException {

		try (InputStream in = newInputStream(secringFile)) {
			PGPSecretKeyRingCollection pgpSec = new PGPSecretKeyRingCollection(
					PGPUtil.getDecoderStream(new BufferedInputStream(in)),
					new JcaKeyFingerprintCalculator());

			String keyId = toFingerprint(signingkey).toLowerCase(Locale.ROOT);
			Iterator<PGPSecretKeyRing> keyrings = pgpSec.getKeyRings();
			while (keyrings.hasNext()) {
				PGPSecretKeyRing keyRing = keyrings.next();
				Iterator<PGPSecretKey> keys = keyRing.getSecretKeys();
				while (keys.hasNext()) {
					PGPSecretKey key = keys.next();
					// try key id
					String fingerprint = Hex
							.toHexString(key.getPublicKey().getFingerprint())
							.toLowerCase(Locale.ROOT);
					if (fingerprint.endsWith(keyId)) {
						return key;
					}
					// try user id
					Iterator<String> userIDs = key.getUserIDs();
					while (userIDs.hasNext()) {
						String userId = userIDs.next();
						if (containsSigningKey(userId, signingKey)) {
							return key;
						}
					}
				}
			}
		}
		return null;
	}

	/**
	 * Return the first public key matching the key id ({@link #signingKey}.
	 *
	 * @param pubringFile
	 *
	 * @return the PGP public key, or {@code null} if none found
	 * @throws IOException
	 *             on I/O related errors
	 * @throws PGPException
	 *             on BouncyCastle errors
	 */
	private PGPPublicKey findPublicKeyInPubring(Path pubringFile)
			throws IOException, PGPException {
		try (InputStream in = newInputStream(pubringFile)) {
			PGPPublicKeyRingCollection pgpPub = new PGPPublicKeyRingCollection(
					new BufferedInputStream(in),
					new JcaKeyFingerprintCalculator());

			String keyId = toFingerprint(signingKey).toLowerCase(Locale.ROOT);
			Iterator<PGPPublicKeyRing> keyrings = pgpPub.getKeyRings();
			while (keyrings.hasNext()) {
				PGPPublicKeyRing keyRing = keyrings.next();
				Iterator<PGPPublicKey> keys = keyRing.getPublicKeys();
				while (keys.hasNext()) {
					PGPPublicKey key = keys.next();
					// try key id
					String fingerprint = Hex.toHexString(key.getFingerprint())
							.toLowerCase(Locale.ROOT);
					if (fingerprint.endsWith(keyId)) {
						return key;
					}
					// try user id
					Iterator<String> userIDs = key.getUserIDs();
					while (userIDs.hasNext()) {
						String userId = userIDs.next();
						if (containsSigningKey(userId, signingKey)) {
							return key;
						}
					}
				}
			}
		}
		return null;
	}

	private PGPPublicKey getPublicKey(KeyBlob blob, byte[] fingerprint)
			throws IOException {
		return ((PublicKeyRingBlob) blob).getPGPPublicKeyRing()
				.getPublicKey(fingerprint);
	}

	private PGPPublicKey getSigningPublicKey(KeyBlob blob) throws IOException {
		PGPPublicKey masterKey = null;
		Iterator<PGPPublicKey> keys = ((PublicKeyRingBlob) blob)
				.getPGPPublicKeyRing().getPublicKeys();
		while (keys.hasNext()) {
			PGPPublicKey key = keys.next();
			// only consider keys that have the [S] usage flag set
			if (isSigningKey(key)) {
				if (key.isMasterKey()) {
					masterKey = key;
				} else {
					return key;
				}
			}
		}
		// return the master key if no other signing key was found or null if
		// the master key did not have the signing flag set
		return masterKey;
	}

	private boolean isSigningKey(PGPPublicKey key) {
		Iterator signatures = key.getSignatures();
		while (signatures.hasNext()) {
			PGPSignature sig = (PGPSignature) signatures.next();
			if ((sig.getHashedSubPackets().getKeyFlags()
					& PGPKeyFlags.CAN_SIGN) > 0) {
				return true;
			}
		}
		return false;
	}

	private KeyBox readKeyBoxFile(Path keyboxFile) throws IOException,
			NoSuchAlgorithmException, NoSuchProviderException,
			NoOpenPgpKeyException {
		if (keyboxFile.toFile().length() == 0) {
			throw new NoOpenPgpKeyException();
		}
		KeyBox keyBox;
		try (InputStream in = new BufferedInputStream(
				newInputStream(keyboxFile))) {
			keyBox = new JcaKeyBoxBuilder().build(in);
		}
		return keyBox;
	}
}