BouncyCastleGpgKeyLocator.java
/*
* Copyright (C) 2018, Salesforce.
* and other copyright owners as documented in the project's IP log.
*
* This program and the accompanying materials are made available
* under the terms of the Eclipse Distribution License v1.0 which
* accompanies this distribution, is reproduced below, and is
* available at http://www.eclipse.org/org/documents/edl-v10.php
*
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or
* without modification, are permitted provided that the following
* conditions are met:
*
* - Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* - Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following
* disclaimer in the documentation and/or other materials provided
* with the distribution.
*
* - Neither the name of the Eclipse Foundation, Inc. nor the
* names of its contributors may be used to endorse or promote
* products derived from this software without specific prior
* written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
* STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
* ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
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.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.PGPPublicKey;
import org.bouncycastle.openpgp.PGPSecretKey;
import org.bouncycastle.openpgp.PGPSecretKeyRing;
import org.bouncycastle.openpgp.PGPSecretKeyRingCollection;
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.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 {
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_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;
}
}
private boolean containsSigningKey(String userId) {
return userId.toLowerCase(Locale.ROOT)
.contains(signingKey.toLowerCase(Locale.ROOT));
}
private PGPPublicKey findPublicKeyByKeyId(KeyBlob keyBlob)
throws IOException {
String keyId = signingKey.toLowerCase(Locale.ROOT);
for (KeyInformation keyInfo : keyBlob.getKeyInformation()) {
String fingerprint = Hex.toHexString(keyInfo.getFingerprint())
.toLowerCase(Locale.ROOT);
if (fingerprint.endsWith(keyId)) {
return getFirstPublicKey(keyBlob);
}
}
return null;
}
private PGPPublicKey findPublicKeyByUserId(KeyBlob keyBlob)
throws IOException {
for (UserID userID : keyBlob.getUserIds()) {
if (containsSigningKey(userID.getUserIDAsString())) {
return getFirstPublicKey(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
*/
private PGPPublicKey findPublicKeyInKeyBox(Path keyboxFile)
throws IOException, NoSuchAlgorithmException,
NoSuchProviderException {
KeyBox keyBox = readKeyBoxFile(keyboxFile);
for (KeyBlob keyBlob : keyBox.getKeyBlobs()) {
if (keyBlob.getType() == BlobType.OPEN_PGP_BLOB) {
PGPPublicKey key = findPublicKeyByKeyId(keyBlob);
if (key != null) {
return key;
}
key = findPublicKeyByUserId(keyBlob);
if (key != null) {
return key;
}
}
}
return null;
}
/**
* Use pubring.kbx when available, if not fallback to secring.gpg or secret
* key path provided to parse and return secret key
*
* @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
* @throws CanceledException
* @throws URISyntaxException
* @throws UnsupportedCredentialItem
*/
public BouncyCastleGpgKey findSecretKey() throws IOException,
NoSuchAlgorithmException, NoSuchProviderException, PGPException,
CanceledException, UnsupportedCredentialItem, URISyntaxException {
if (exists(USER_KEYBOX_PATH)) {
PGPPublicKey publicKey = //
findPublicKeyInKeyBox(USER_KEYBOX_PATH);
if (publicKey != null) {
return findSecretKeyForKeyBoxPublicKey(publicKey,
USER_KEYBOX_PATH);
}
throw new PGPException(MessageFormat
.format(JGitText.get().gpgNoPublicKeyFound, signingKey));
} else if (exists(USER_PGP_LEGACY_SECRING_FILE)) {
PGPSecretKey secretKey = findSecretKeyInLegacySecring(signingKey,
USER_PGP_LEGACY_SECRING_FILE);
if (secretKey != null) {
if (!secretKey.isSigningKey()) {
throw new PGPException(MessageFormat.format(
JGitText.get().gpgNotASigningKey, signingKey));
}
return new BouncyCastleGpgKey(secretKey, USER_PGP_LEGACY_SECRING_FILE);
}
throw new PGPException(MessageFormat.format(
JGitText.get().gpgNoKeyInLegacySecring, signingKey));
}
throw new PGPException(JGitText.get().gpgNoKeyring);
}
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();
throw new PGPException(MessageFormat.format(
JGitText.get().gpgNoSecretKeyForPublicKey,
Long.toHexString(publicKey.getKeyID())));
} 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 = 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)) {
return key;
}
}
}
}
}
return null;
}
private PGPPublicKey getFirstPublicKey(KeyBlob keyBlob) throws IOException {
return ((PublicKeyRingBlob) keyBlob).getPGPPublicKeyRing()
.getPublicKey();
}
private KeyBox readKeyBoxFile(Path keyboxFile) throws IOException,
NoSuchAlgorithmException, NoSuchProviderException {
KeyBox keyBox;
try (InputStream in = new BufferedInputStream(
newInputStream(keyboxFile))) {
keyBox = new JcaKeyBoxBuilder().build(in);
}
return keyBox;
}
}