/*
* Copyright (C) 2018, 2021 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.gpg.bc.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.FileNotFoundException;
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.NoSuchFileException;
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.ArrayList;
import java.util.Iterator;
import java.util.List;
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.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 ~/.gnupg/private-keys-v1.d
or
* ~/.gnupg/secring.gpg
*/
public 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;
}
/** Thrown if we try to read an encrypted private key without password. */
private static class EncryptedPgpKeyException extends RuntimeException {
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.
*
* 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. *
* * @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. ** Not all formats are supported; only formats starting with '=', '<', * '@', and '*' are handled. Any other format results in a case-insensitive * substring match. *
* * @param userId * of a key * @param signingKeySpec * GPG key identification * @return whether the {@code userId} matches * @see GPG * Documentation: How to Specify a User ID */ 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) .equalsIgnoreCase(toMatch.substring(0, stop)); } case '@': { int begin = userId.indexOf('<'); int end = userId.indexOf('>', begin + 1); return begin >= 0 && end > begin + 1 && containsIgnoreCase(userId.substring(begin + 1, end), toMatch); } default: if (toMatch.trim().isEmpty()) { return false; } return containsIgnoreCase(userId, toMatch); } } private static boolean containsIgnoreCase(String a, String b) { int alength = a.length(); int blength = b.length(); for (int i = 0; i + blength <= alength; i++) { if (a.regionMatches(true, i, b, 0, blength)) { return true; } } return false; } private static String toFingerprint(String keyId) { if (keyId.startsWith("0x")) { //$NON-NLS-1$ return keyId.substring(2); } return keyId; } static PGPPublicKey findPublicKey(String fingerprint, String keySpec) throws IOException, PGPException { PGPPublicKey result = findPublicKeyInPubring(USER_PGP_PUBRING_FILE, fingerprint, keySpec); if (result == null && exists(USER_KEYBOX_PATH)) { try { result = findPublicKeyInKeyBox(USER_KEYBOX_PATH, fingerprint, keySpec); } catch (NoSuchAlgorithmException | NoSuchProviderException | IOException | NoOpenPgpKeyException e) { log.error(e.getMessage(), e); } } return result; } private static PGPPublicKey findPublicKeyByKeyId(KeyBlob keyBlob, String keyId) throws IOException { 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 static PGPPublicKey findPublicKeyByUserId(KeyBlob keyBlob, String keySpec) throws IOException { for (UserID userID : keyBlob.getUserIds()) { if (containsSigningKey(userID.getUserIDAsString(), keySpec)) { return getSigningPublicKey(keyBlob); } } return null; } /** * Finds a public key associated with the signing key. * * @param keyboxFile * the KeyBox file * @param keyId * to look for, may be null * @param keySpec * to look for * @return publicKey the public key (maybenull
)
* @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 static PGPPublicKey findPublicKeyInKeyBox(Path keyboxFile,
String keyId, String keySpec)
throws IOException, NoSuchAlgorithmException,
NoSuchProviderException, NoOpenPgpKeyException {
KeyBox keyBox = readKeyBoxFile(keyboxFile);
String id = keyId != null ? keyId
: toFingerprint(keySpec).toLowerCase(Locale.ROOT);
boolean hasOpenPgpKey = false;
for (KeyBlob keyBlob : keyBox.getKeyBlobs()) {
if (keyBlob.getType() == BlobType.OPEN_PGP_BLOB) {
hasOpenPgpKey = true;
PGPPublicKey key = findPublicKeyByKeyId(keyBlob, id);
if (key != null) {
return key;
}
key = findPublicKeyByUserId(keyBlob, keySpec);
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.
* * If there is no private key directory (or it doesn't contain any keys), * try to find the key in secring.gpg directly. *
* * @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, null, signingKey); if (publicKey != null) { key = findSecretKeyForKeyBoxPublicKey(publicKey, USER_KEYBOX_PATH); if (key != null) { return key; } throw new PGPException(MessageFormat.format( BCText.get().gpgNoSecretKeyForPublicKey, Long.toHexString(publicKey.getKeyID()))); } throw new PGPException(MessageFormat.format( BCText.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, null, signingKey); 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( BCText.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( BCText.get().gpgNoSecretKeyForPublicKey, Long.toHexString(publicKey.getKeyID()))); } else if (hasSecring) { // publicKey == null: user has _only_ pubring.gpg/secring.gpg. throw new PGPException(MessageFormat.format( BCText.get().gpgNoKeyInLegacySecring, signingKey)); } else { throw new PGPException(BCText.get().gpgNoKeyring); } } private boolean hasKeyFiles(Path dir) { try (DirectoryStream