diff options
author | Matthias Sohn <matthias.sohn@sap.com> | 2020-04-27 00:58:28 +0200 |
---|---|---|
committer | Matthias Sohn <matthias.sohn@sap.com> | 2020-06-01 01:26:22 +0200 |
commit | 77848d635b76d8294697ffaf11acf51256df2a5b (patch) | |
tree | 9a91a25512d2dff89bb9cc0b336279eef2df18e5 /org.eclipse.jgit.gpg.bc/src | |
parent | 0b2d41b8584e16d6f7abeca92eaae326033b4489 (diff) | |
download | jgit-77848d635b76d8294697ffaf11acf51256df2a5b.tar.gz jgit-77848d635b76d8294697ffaf11acf51256df2a5b.zip |
Decouple BouncyCastle from JGit Core
Motivation: BouncyCastle serves as 'default' implementation of
the GPG Signer. If a client application does not use it there is no need
to pull in this dependency, especially since BouncyCastle is a large
library.
Move the classes depending on BouncyCastle to an OSGi fragment extending
the org.eclipse.jgit bundle. They are moved to a distinct internal
package in order to avoid split packages. This doesn't break public API
since these classes were already in an internal package before this
change.
Add a new feature org.eclipse.jgit.gpg.bc to enable installation. With
that users can now decide if they want to install it.
Attempts to sign a commit if org.eclipse.jgit.gpg.bc isn't available
will result in ServiceUnavailableException being thrown.
Bug: 559106
Change-Id: I42fd6c00002e17aa9a7be96ae434b538ea86ccf8
Also-by: Michael Dardis <git@md-5.net>
Signed-off-by: Michael Dardis <git@md-5.net>
Signed-off-by: Matthias Sohn <matthias.sohn@sap.com>
Signed-off-by: David Ostrovsky <david@ostrovsky.org>
Diffstat (limited to 'org.eclipse.jgit.gpg.bc/src')
5 files changed, 921 insertions, 0 deletions
diff --git a/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BCText.java b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BCText.java new file mode 100644 index 0000000000..1a00b0fc79 --- /dev/null +++ b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BCText.java @@ -0,0 +1,33 @@ +package org.eclipse.jgit.gpg.bc.internal; + +import org.eclipse.jgit.nls.NLS; +import org.eclipse.jgit.nls.TranslationBundle; + +/** + * Externalized text messages for localization. + */ +public final class BCText extends TranslationBundle { + + /** + * Get an instance of this translation bundle. + * + * @return an instance of this translation bundle + */ + public static BCText get() { + return NLS.getBundleFor(BCText.class); + } + + // @formatter:off + /***/ public String credentialPassphrase; + /***/ public String gpgFailedToParseSecretKey; + /***/ public String gpgNoCredentialsProvider; + /***/ public String gpgNoKeyring; + /***/ public String gpgNoKeyInLegacySecring; + /***/ public String gpgNoPublicKeyFound; + /***/ public String gpgNoSecretKeyForPublicKey; + /***/ public String gpgNotASigningKey; + /***/ public String gpgKeyInfo; + /***/ public String gpgSigningCancelled; + /***/ public String unableToSignCommitNoSecretKey; + +} diff --git a/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgKey.java b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgKey.java new file mode 100644 index 0000000000..bc26cb98a3 --- /dev/null +++ b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgKey.java @@ -0,0 +1,38 @@ +/* + * 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.gpg.bc.internal; + +import java.nio.file.Path; + +import org.bouncycastle.openpgp.PGPSecretKey; + +/** + * Container which holds a {@link #getSecretKey()} together with the + * {@link #getOrigin() path it was loaded from}. + */ +class BouncyCastleGpgKey { + + private PGPSecretKey secretKey; + + private Path origin; + + public BouncyCastleGpgKey(PGPSecretKey secretKey, Path origin) { + this.secretKey = secretKey; + this.origin = origin; + } + + public PGPSecretKey getSecretKey() { + return secretKey; + } + + public Path getOrigin() { + return origin; + } +}
\ No newline at end of file diff --git a/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgKeyLocator.java b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgKeyLocator.java new file mode 100644 index 0000000000..e6a48f859b --- /dev/null +++ b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgKeyLocator.java @@ -0,0 +1,613 @@ +/* + * 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.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.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.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> + */ +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; + + } + + 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 '=', '<', + * '@', 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( + 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); + 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<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(BCText.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( + BCText.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( + BCText.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; + } +} diff --git a/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgKeyPassphrasePrompt.java b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgKeyPassphrasePrompt.java new file mode 100644 index 0000000000..740f597a9f --- /dev/null +++ b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgKeyPassphrasePrompt.java @@ -0,0 +1,100 @@ +/*- + * Copyright (C) 2019, 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 java.net.URISyntaxException; +import java.nio.file.Path; +import java.text.MessageFormat; + +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.util.encoders.Hex; +import org.eclipse.jgit.api.errors.CanceledException; +import org.eclipse.jgit.errors.UnsupportedCredentialItem; +import org.eclipse.jgit.transport.CredentialItem.CharArrayType; +import org.eclipse.jgit.transport.CredentialItem.InformationalMessage; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.URIish; + +/** + * Prompts for a passphrase and caches it until {@link #clear() cleared}. + * <p> + * Implements {@link AutoCloseable} so it can be used within a + * try-with-resources block. + * </p> + */ +class BouncyCastleGpgKeyPassphrasePrompt implements AutoCloseable { + + private CharArrayType passphrase; + + private CredentialsProvider credentialsProvider; + + public BouncyCastleGpgKeyPassphrasePrompt( + CredentialsProvider credentialsProvider) { + this.credentialsProvider = credentialsProvider; + } + + /** + * Clears any cached passphrase + */ + public void clear() { + if (passphrase != null) { + passphrase.clear(); + passphrase = null; + } + } + + @Override + public void close() { + clear(); + } + + private URIish createURI(Path keyLocation) throws URISyntaxException { + return new URIish(keyLocation.toUri().toString()); + } + + /** + * Prompts use for a passphrase unless one was cached from a previous + * prompt. + * + * @param keyFingerprint + * the fingerprint to show to the user during prompting + * @param keyLocation + * the location the key was loaded from + * @return the passphrase (maybe <code>null</code>) + * @throws PGPException + * @throws CanceledException + * in case passphrase was not entered by user + * @throws URISyntaxException + * @throws UnsupportedCredentialItem + */ + public char[] getPassphrase(byte[] keyFingerprint, Path keyLocation) + throws PGPException, CanceledException, UnsupportedCredentialItem, + URISyntaxException { + if (passphrase == null) { + passphrase = new CharArrayType(BCText.get().credentialPassphrase, + true); + } + + if (credentialsProvider == null) { + throw new PGPException(BCText.get().gpgNoCredentialsProvider); + } + + if (passphrase.getValue() == null + && !credentialsProvider.get(createURI(keyLocation), + new InformationalMessage( + MessageFormat.format(BCText.get().gpgKeyInfo, + Hex.toHexString(keyFingerprint))), + passphrase)) { + throw new CanceledException(BCText.get().gpgSigningCancelled); + } + return passphrase.getValue(); + } + +} diff --git a/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSigner.java b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSigner.java new file mode 100644 index 0000000000..fc6c156d47 --- /dev/null +++ b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSigner.java @@ -0,0 +1,137 @@ +/* + * 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.gpg.bc.internal; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.URISyntaxException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.Security; + +import org.bouncycastle.bcpg.ArmoredOutputStream; +import org.bouncycastle.bcpg.BCPGOutputStream; +import org.bouncycastle.bcpg.HashAlgorithmTags; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPrivateKey; +import org.bouncycastle.openpgp.PGPSecretKey; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.openpgp.PGPSignatureGenerator; +import org.bouncycastle.openpgp.PGPSignatureSubpacketGenerator; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder; +import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.annotations.Nullable; +import org.eclipse.jgit.api.errors.CanceledException; +import org.eclipse.jgit.api.errors.JGitInternalException; +import org.eclipse.jgit.errors.UnsupportedCredentialItem; +import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.GpgSignature; +import org.eclipse.jgit.lib.GpgSigner; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.transport.CredentialsProvider; + +/** + * GPG Signer using BouncyCastle library + */ +public class BouncyCastleGpgSigner extends GpgSigner { + + private static void registerBouncyCastleProviderIfNecessary() { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + /** + * Create a new instance. + * <p> + * The BounceCastleProvider will be registered if necessary. + * </p> + */ + public BouncyCastleGpgSigner() { + registerBouncyCastleProviderIfNecessary(); + } + + @Override + public boolean canLocateSigningKey(@Nullable String gpgSigningKey, + PersonIdent committer, CredentialsProvider credentialsProvider) + throws CanceledException { + try (BouncyCastleGpgKeyPassphrasePrompt passphrasePrompt = new BouncyCastleGpgKeyPassphrasePrompt( + credentialsProvider)) { + BouncyCastleGpgKey gpgKey = locateSigningKey(gpgSigningKey, + committer, passphrasePrompt); + return gpgKey != null; + } catch (PGPException | IOException | NoSuchAlgorithmException + | NoSuchProviderException | URISyntaxException e) { + return false; + } + } + + private BouncyCastleGpgKey locateSigningKey(@Nullable String gpgSigningKey, + PersonIdent committer, + BouncyCastleGpgKeyPassphrasePrompt passphrasePrompt) + throws CanceledException, UnsupportedCredentialItem, IOException, + NoSuchAlgorithmException, NoSuchProviderException, PGPException, + URISyntaxException { + if (gpgSigningKey == null || gpgSigningKey.isEmpty()) { + gpgSigningKey = '<' + committer.getEmailAddress() + '>'; + } + + BouncyCastleGpgKeyLocator keyHelper = new BouncyCastleGpgKeyLocator( + gpgSigningKey, passphrasePrompt); + + return keyHelper.findSecretKey(); + } + + @Override + public void sign(@NonNull CommitBuilder commit, + @Nullable String gpgSigningKey, @NonNull PersonIdent committer, + CredentialsProvider credentialsProvider) throws CanceledException { + try (BouncyCastleGpgKeyPassphrasePrompt passphrasePrompt = new BouncyCastleGpgKeyPassphrasePrompt( + credentialsProvider)) { + BouncyCastleGpgKey gpgKey = locateSigningKey(gpgSigningKey, + committer, passphrasePrompt); + PGPSecretKey secretKey = gpgKey.getSecretKey(); + if (secretKey == null) { + throw new JGitInternalException( + BCText.get().unableToSignCommitNoSecretKey); + } + char[] passphrase = passphrasePrompt.getPassphrase( + secretKey.getPublicKey().getFingerprint(), + gpgKey.getOrigin()); + PGPPrivateKey privateKey = secretKey + .extractPrivateKey(new JcePBESecretKeyDecryptorBuilder() + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .build(passphrase)); + PGPSignatureGenerator signatureGenerator = new PGPSignatureGenerator( + new JcaPGPContentSignerBuilder( + secretKey.getPublicKey().getAlgorithm(), + HashAlgorithmTags.SHA256).setProvider( + BouncyCastleProvider.PROVIDER_NAME)); + signatureGenerator.init(PGPSignature.BINARY_DOCUMENT, privateKey); + PGPSignatureSubpacketGenerator subpacketGenerator = new PGPSignatureSubpacketGenerator(); + subpacketGenerator.setIssuerFingerprint(false, + secretKey.getPublicKey()); + signatureGenerator + .setHashedSubpackets(subpacketGenerator.generate()); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try (BCPGOutputStream out = new BCPGOutputStream( + new ArmoredOutputStream(buffer))) { + signatureGenerator.update(commit.build()); + signatureGenerator.generate().encode(out); + } + commit.setGpgSignature(new GpgSignature(buffer.toByteArray())); + } catch (PGPException | IOException | NoSuchAlgorithmException + | NoSuchProviderException | URISyntaxException e) { + throw new JGitInternalException(e.getMessage(), e); + } + } +} |