| /* |
| * 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; |
| } |
| } |