diff options
author | Matthias Sohn <matthias.sohn@sap.com> | 2021-02-25 10:29:07 +0100 |
---|---|---|
committer | Matthias Sohn <matthias.sohn@sap.com> | 2021-02-28 00:58:04 +0100 |
commit | f6597971991e3350df568b0cde05c014dcd69c47 (patch) | |
tree | cb61592af3f53da45174beed517b3284d7bd55c6 /org.eclipse.jgit.gpg.bc | |
parent | 286ad23cb56ffeac77d4bfd03be575358fd5217c (diff) | |
parent | 789c0479a9294417db0375cce9f1949fe9052d8c (diff) | |
download | jgit-f6597971991e3350df568b0cde05c014dcd69c47.tar.gz jgit-f6597971991e3350df568b0cde05c014dcd69c47.zip |
Merge branch 'master' into next
* master: (143 commits)
Prepare 5.11.0-SNAPSHOT builds
JGit v5.11.0.202102240950-m3
[releng] japicmp: update last release version
IgnoreNode: include path to file for invalid .gitignore patterns
FastIgnoreRule: include bad pattern in log message
init: add config option to set default for the initial branch name
init: allow specifying the initial branch name for the new repository
Fail clone if initial branch doesn't exist in remote repository
GPG: fix reading unprotected old-format secret keys
Update Orbit to S20210216215844
Add missing bazel dependency for o.e.j.gpg.bc.test
GPG: handle extended private key format
dfs: handle short copies
[GPG] Provide a factory for the BouncyCastleGpgSigner
Fix boxing warnings
GPG: compute the keygrip to find a secret key
GPG signature verification via BouncyCastle
Post commit hook failure should not cause commit failure
Allow to define additional Hook classes outside JGit
GitHook: use default charset for output and error streams
...
Change-Id: I689f4070e79f4a0ac1c02b35698ccaab68ad2f34
Diffstat (limited to 'org.eclipse.jgit.gpg.bc')
17 files changed, 2723 insertions, 166 deletions
diff --git a/org.eclipse.jgit.gpg.bc/META-INF/MANIFEST.MF b/org.eclipse.jgit.gpg.bc/META-INF/MANIFEST.MF index b59a850ea3..48aad320dc 100644 --- a/org.eclipse.jgit.gpg.bc/META-INF/MANIFEST.MF +++ b/org.eclipse.jgit.gpg.bc/META-INF/MANIFEST.MF @@ -8,22 +8,30 @@ Bundle-Vendor: %Bundle-Vendor Bundle-Localization: plugin Bundle-Version: 6.0.0.qualifier Bundle-RequiredExecutionEnvironment: JavaSE-1.8 -Import-Package: org.bouncycastle.bcpg;version="[1.65.0,2.0.0)", +Import-Package: org.bouncycastle.asn1;version="[1.65.0,2.0.0)", + org.bouncycastle.asn1.cryptlib;version="[1.65.0,2.0.0)", + org.bouncycastle.asn1.x9;version="[1.65.0,2.0.0)", + org.bouncycastle.bcpg;version="[1.65.0,2.0.0)", + org.bouncycastle.bcpg.sig;version="[1.65.0,2.0.0)", + org.bouncycastle.crypto.ec;version="[1.65.0,2.0.0)", org.bouncycastle.gpg;version="[1.65.0,2.0.0)", org.bouncycastle.gpg.keybox;version="[1.65.0,2.0.0)", org.bouncycastle.gpg.keybox.jcajce;version="[1.65.0,2.0.0)", + org.bouncycastle.jcajce.interfaces;version="[1.65.0,2.0.0)", + org.bouncycastle.jcajce.util;version="[1.65.0,2.0.0)", org.bouncycastle.jce.provider;version="[1.65.0,2.0.0)", + org.bouncycastle.math.ec;version="[1.65.0,2.0.0)", + org.bouncycastle.math.field;version="[1.65.0,2.0.0)", org.bouncycastle.openpgp;version="[1.65.0,2.0.0)", + org.bouncycastle.openpgp.jcajce;version="[1.65.0,2.0.0)", org.bouncycastle.openpgp.operator;version="[1.65.0,2.0.0)", org.bouncycastle.openpgp.operator.jcajce;version="[1.65.0,2.0.0)", + org.bouncycastle.util;version="[1.65.0,2.0.0)", org.bouncycastle.util.encoders;version="[1.65.0,2.0.0)", + org.bouncycastle.util.io;version="[1.65.0,2.0.0)", org.eclipse.jgit.annotations;version="[6.0.0,6.1.0)", org.eclipse.jgit.api.errors;version="[6.0.0,6.1.0)", - org.eclipse.jgit.errors;version="[6.0.0,6.1.0)", - org.eclipse.jgit.lib;version="[6.0.0,6.1.0)", - org.eclipse.jgit.nls;version="[6.0.0,6.1.0)", - org.eclipse.jgit.transport;version="[6.0.0,6.1.0)", - org.eclipse.jgit.util;version="[6.0.0,6.1.0)", org.slf4j;version="[1.7.0,2.0.0)" -Export-Package: org.eclipse.jgit.gpg.bc.internal;version="6.0.0"; - x-friends:="org.eclipse.jgit.gpg.bc.test" +Export-Package: org.eclipse.jgit.gpg.bc;version="6.0.0", + org.eclipse.jgit.gpg.bc.internal;version="6.0.0";x-friends:="org.eclipse.jgit.gpg.bc.test", + org.eclipse.jgit.gpg.bc.internal.keys;version="6.0.0";x-friends:="org.eclipse.jgit.gpg.bc.test" diff --git a/org.eclipse.jgit.gpg.bc/META-INF/SOURCE-MANIFEST.MF b/org.eclipse.jgit.gpg.bc/META-INF/SOURCE-MANIFEST.MF index 9e0d921ea2..58bd8ea041 100644 --- a/org.eclipse.jgit.gpg.bc/META-INF/SOURCE-MANIFEST.MF +++ b/org.eclipse.jgit.gpg.bc/META-INF/SOURCE-MANIFEST.MF @@ -5,3 +5,4 @@ Bundle-SymbolicName: org.eclipse.jgit.gpg.bc.source Bundle-Vendor: Eclipse.org - JGit Bundle-Version: 6.0.0.qualifier Eclipse-SourceBundle: org.eclipse.jgit.gpg.bc;version="6.0.0.qualifier";roots="." + diff --git a/org.eclipse.jgit.gpg.bc/about.html b/org.eclipse.jgit.gpg.bc/about.html index f971af18d0..fc527d5a3a 100644 --- a/org.eclipse.jgit.gpg.bc/about.html +++ b/org.eclipse.jgit.gpg.bc/about.html @@ -11,7 +11,7 @@ margin: 0.25in 0.5in 0.25in 0.5in; tab-interval: 0.5in; } - p { + p { margin-left: auto; margin-top: 0.5em; margin-bottom: 0.5em; @@ -36,60 +36,53 @@ <p>Copyright (c) 2007, Eclipse Foundation, Inc. and its licensors. </p> <p>All rights reserved.</p> -<p>Redistribution and use in source and binary forms, with or without modification, +<p>Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: -<ul><li>Redistributions of source code must retain the above copyright notice, +<ul><li>Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. </li> -<li>Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation +<li>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. </li> -<li>Neither the name of the Eclipse Foundation, Inc. nor the names of its - contributors may be used to endorse or promote products derived from +<li>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. </li></ul> </p> -<p>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 +<p>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.</p> <hr> -<p><b>SHA-1 UbcCheck - MIT</b></p> +<p><b>org.eclipse.jgit.gpg.bc.internal.keys.SExprParser - MIT</b></p> -<p>Copyright (c) 2017:</p> -<div class="ubc-name"> -Marc Stevens -Cryptology Group -Centrum Wiskunde & Informatica -P.O. Box 94079, 1090 GB Amsterdam, Netherlands -marc@marc-stevens.nl -</div> -<div class="ubc-name"> -Dan Shumow -Microsoft Research -danshu@microsoft.com -</div> -<p>Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +<p>Copyright (c) 2000-2021 The Legion of the Bouncy Castle Inc. +(<a href="https://www.bouncycastle.org">https://www.bouncycastle.org</a>)</p> + +<p> +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: +</p> +<p> +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. +</p> +<p> +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. </p> -<ul><li>The above copyright notice and this permission notice shall be included -in all copies or substantial portions of the Software.</li></ul> -<p>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE.</p> </body> diff --git a/org.eclipse.jgit.gpg.bc/resources/META-INF/services/org.eclipse.jgit.lib.GpgSignatureVerifierFactory b/org.eclipse.jgit.gpg.bc/resources/META-INF/services/org.eclipse.jgit.lib.GpgSignatureVerifierFactory new file mode 100644 index 0000000000..17ab30fba7 --- /dev/null +++ b/org.eclipse.jgit.gpg.bc/resources/META-INF/services/org.eclipse.jgit.lib.GpgSignatureVerifierFactory @@ -0,0 +1 @@ +org.eclipse.jgit.gpg.bc.internal.BouncyCastleGpgSignatureVerifierFactory
\ No newline at end of file diff --git a/org.eclipse.jgit.gpg.bc/resources/org/eclipse/jgit/gpg/bc/internal/BCText.properties b/org.eclipse.jgit.gpg.bc/resources/org/eclipse/jgit/gpg/bc/internal/BCText.properties index 1441c63e8e..e4b1baba1f 100644 --- a/org.eclipse.jgit.gpg.bc/resources/org/eclipse/jgit/gpg/bc/internal/BCText.properties +++ b/org.eclipse.jgit.gpg.bc/resources/org/eclipse/jgit/gpg/bc/internal/BCText.properties @@ -1,11 +1,36 @@ +corrupt25519Key=Ed25519/Curve25519 public key has wrong length: {0} credentialPassphrase=Passphrase -gpgFailedToParseSecretKey=Failed to parse secret key file in directory: {0}. Is the entered passphrase correct? +cryptCipherError=Cannot create cipher to decrypt: {0} +cryptWrongDecryptedLength=Decrypted key has wrong length; expected {0} bytes, got only {1} bytes +gpgFailedToParseSecretKey=Failed to parse secret key file {0}. Is the entered passphrase correct? gpgNoCredentialsProvider=missing credentials provider +gpgNoKeygrip=Cannot find key {0}: cannot determine key grip gpgNoKeyring=neither pubring.kbx nor secring.gpg files found gpgNoKeyInLegacySecring=no matching secret key found in legacy secring.gpg for key or user id: {0} gpgNoPublicKeyFound=Unable to find a public-key with key or user id: {0} gpgNoSecretKeyForPublicKey=unable to find associated secret key for public key: {0} +gpgNoSuchAlgorithm=Cannot decrypt encrypted secret key: encryption algorithm {0} is not available gpgNotASigningKey=Secret key ({0}) is not suitable for signing gpgKeyInfo=GPG Key (fingerprint {0}) gpgSigningCancelled=Signing was cancelled +nonSignatureError=Signature does not decode into a signature object +secretKeyTooShort=Secret key file corrupt; only {0} bytes read +sexprHexNotClosed=Hex number in s-expression not closed +sexprHexOdd=Hex number in s-expression has an odd number of digits +sexprStringInvalidEscape=Invalid escape {0} in s-expression +sexprStringInvalidEscapeAtEnd=Invalid s-expression: quoted string ends with escape character +sexprStringInvalidHexEscape=Invalid hex escape in s-expression +sexprStringInvalidOctalEscape=Invalid octal escape in s-expression +sexprStringNotClosed=String in s-expression not closed +sexprUnhandled=Unhandled token {0} in s-expression +signatureInconsistent=Inconsistent signature; key ID {0} does not match issuer fingerprint {1} +signatureKeyLookupError=Error occurred while looking for public key +signatureNoKeyInfo=No way to determine a public key from the signature +signatureNoPublicKey=No public key found to verify the signature +signatureParseError=Signature cannot be parsed +signatureVerificationError=Signature verification failed unableToSignCommitNoSecretKey=Unable to sign commit. Signing key not available. +uncompressed25519Key=Cannot handle ed25519 public key with uncompressed data: {0} +unknownCurve=Unknown curve {0} +unknownCurveParameters=Curve {0} does not have a prime field +unknownKeyType=Unknown key type {0}
\ No newline at end of file diff --git a/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/BouncyCastleGpgSignerFactory.java b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/BouncyCastleGpgSignerFactory.java new file mode 100644 index 0000000000..fdd1a2b11a --- /dev/null +++ b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/BouncyCastleGpgSignerFactory.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2021 Thomas Wolf <thomas.wolf@paranor.ch> 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; + +import org.eclipse.jgit.gpg.bc.internal.BouncyCastleGpgSigner; +import org.eclipse.jgit.lib.GpgSigner; + +/** + * Factory for creating a {@link GpgSigner} based on Bouncy Castle. + * + * @since 5.11 + */ +public final class BouncyCastleGpgSignerFactory { + + private BouncyCastleGpgSignerFactory() { + // No instantiation + } + + /** + * Creates a new {@link GpgSigner}. + * + * @return the {@link GpgSigner} + */ + public static GpgSigner create() { + return new BouncyCastleGpgSigner(); + } +} 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 index 1a00b0fc79..aedf8a5be5 100644 --- 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 @@ -1,3 +1,12 @@ +/* + * 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 org.eclipse.jgit.nls.NLS; @@ -18,16 +27,41 @@ public final class BCText extends TranslationBundle { } // @formatter:off + /***/ public String corrupt25519Key; /***/ public String credentialPassphrase; + /***/ public String cryptCipherError; + /***/ public String cryptWrongDecryptedLength; /***/ public String gpgFailedToParseSecretKey; /***/ public String gpgNoCredentialsProvider; + /***/ public String gpgNoKeygrip; /***/ public String gpgNoKeyring; /***/ public String gpgNoKeyInLegacySecring; /***/ public String gpgNoPublicKeyFound; /***/ public String gpgNoSecretKeyForPublicKey; + /***/ public String gpgNoSuchAlgorithm; /***/ public String gpgNotASigningKey; /***/ public String gpgKeyInfo; /***/ public String gpgSigningCancelled; + /***/ public String nonSignatureError; + /***/ public String secretKeyTooShort; + /***/ public String sexprHexNotClosed; + /***/ public String sexprHexOdd; + /***/ public String sexprStringInvalidEscape; + /***/ public String sexprStringInvalidEscapeAtEnd; + /***/ public String sexprStringInvalidHexEscape; + /***/ public String sexprStringInvalidOctalEscape; + /***/ public String sexprStringNotClosed; + /***/ public String sexprUnhandled; + /***/ public String signatureInconsistent; + /***/ public String signatureKeyLookupError; + /***/ public String signatureNoKeyInfo; + /***/ public String signatureNoPublicKey; + /***/ public String signatureParseError; + /***/ public String signatureVerificationError; /***/ public String unableToSignCommitNoSecretKey; + /***/ public String uncompressed25519Key; + /***/ public String unknownCurve; + /***/ public String unknownCurveParameters; + /***/ public String unknownKeyType; } 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 index eca45072bf..cf4d3d2340 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2020 Salesforce and others + * 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 @@ -14,25 +14,22 @@ 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; @@ -50,15 +47,15 @@ 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.gpg.bc.internal.keys.KeyGrip; +import org.eclipse.jgit.gpg.bc.internal.keys.SecretKeys; import org.eclipse.jgit.util.FS; import org.eclipse.jgit.util.StringUtils; import org.eclipse.jgit.util.SystemReader; @@ -78,17 +75,10 @@ public class BouncyCastleGpgKeyLocator { } - /** 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(); + static final Path GPG_DIRECTORY = findGpgDirectory(); private static final Path USER_KEYBOX_PATH = GPG_DIRECTORY .resolve("pubring.kbx"); //$NON-NLS-1$ @@ -155,16 +145,13 @@ public class BouncyCastleGpgKeyLocator { private PGPSecretKey attemptParseSecretKey(Path keyFile, PGPDigestCalculatorProvider calculatorProvider, - PBEProtectionRemoverFactory passphraseProvider, - PGPPublicKey publicKey) { + SecretKeys.PassphraseSupplier passphraseSupplier, + PGPPublicKey publicKey) + throws IOException, PGPException, CanceledException, + UnsupportedCredentialItem, URISyntaxException { 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; + return SecretKeys.readSecretKey(in, calculatorProvider, + passphraseSupplier, publicKey); } } @@ -219,33 +206,60 @@ public class BouncyCastleGpgKeyLocator { int stop = toMatch.indexOf('>'); return begin >= 0 && end > begin + 1 && stop > 0 && userId.substring(begin + 1, end) - .equals(toMatch.substring(0, stop)); + .equalsIgnoreCase(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); + && containsIgnoreCase(userId.substring(begin + 1, end), + toMatch); } default: if (toMatch.trim().isEmpty()) { return false; } - return userId.toLowerCase(Locale.ROOT) - .contains(toMatch.toLowerCase(Locale.ROOT)); + 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 String toFingerprint(String keyId) { + private static String toFingerprint(String keyId) { if (keyId.startsWith("0x")) { //$NON-NLS-1$ return keyId.substring(2); } return keyId; } - private PGPPublicKey findPublicKeyByKeyId(KeyBlob keyBlob) + 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 { - String keyId = toFingerprint(signingKey).toLowerCase(Locale.ROOT); if (keyId.isEmpty()) { return null; } @@ -259,10 +273,11 @@ public class BouncyCastleGpgKeyLocator { return null; } - private PGPPublicKey findPublicKeyByUserId(KeyBlob keyBlob) + private static PGPPublicKey findPublicKeyByUserId(KeyBlob keyBlob, + String keySpec) throws IOException { for (UserID userID : keyBlob.getUserIds()) { - if (containsSigningKey(userID.getUserIDAsString(), signingKey)) { + if (containsSigningKey(userID.getUserIDAsString(), keySpec)) { return getSigningPublicKey(keyBlob); } } @@ -274,6 +289,10 @@ public class BouncyCastleGpgKeyLocator { * * @param keyboxFile * the KeyBox file + * @param keyId + * to look for, may be null + * @param keySpec + * to look for * @return publicKey the public key (maybe <code>null</code>) * @throws IOException * in case of problems reading the file @@ -282,19 +301,22 @@ public class BouncyCastleGpgKeyLocator { * @throws NoOpenPgpKeyException * if the file does not contain any OpenPGP key */ - private PGPPublicKey findPublicKeyInKeyBox(Path keyboxFile) + 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); + PGPPublicKey key = findPublicKeyByKeyId(keyBlob, id); if (key != null) { return key; } - key = findPublicKeyByUserId(keyBlob); + key = findPublicKeyByUserId(keyBlob, keySpec); if (key != null) { return key; } @@ -338,7 +360,8 @@ public class BouncyCastleGpgKeyLocator { // pubring.gpg also try secring.gpg to find the secret key. if (exists(USER_KEYBOX_PATH)) { try { - publicKey = findPublicKeyInKeyBox(USER_KEYBOX_PATH); + publicKey = findPublicKeyInKeyBox(USER_KEYBOX_PATH, null, + signingKey); if (publicKey != null) { key = findSecretKeyForKeyBoxPublicKey(publicKey, USER_KEYBOX_PATH); @@ -361,7 +384,8 @@ public class BouncyCastleGpgKeyLocator { } } if (exists(USER_PGP_PUBRING_FILE)) { - publicKey = findPublicKeyInPubring(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 @@ -433,67 +457,59 @@ public class BouncyCastleGpgKeyLocator { 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(); - - try (Stream<Path> keyFiles = Files.walk(USER_SECRET_KEY_DIR)) { - List<Path> allPaths = keyFiles.filter(Files::isRegularFile) - .collect(Collectors.toCollection(ArrayList::new)); - if (allPaths.isEmpty()) { - return null; + byte[] keyGrip = null; + try { + keyGrip = KeyGrip.getKeyGrip(publicKey); + } catch (PGPException e) { + throw new PGPException( + MessageFormat.format(BCText.get().gpgNoKeygrip, + Hex.toHexString(publicKey.getFingerprint())), + e); + } + String filename = Hex.toHexString(keyGrip).toUpperCase(Locale.ROOT) + + ".key"; //$NON-NLS-1$ + Path keyFile = USER_SECRET_KEY_DIR.resolve(filename); + if (!Files.exists(keyFile)) { + return null; + } + boolean clearPrompt = false; + try { + PGPDigestCalculatorProvider calculatorProvider = new JcaPGPDigestCalculatorProviderBuilder() + .build(); + clearPrompt = true; + PGPSecretKey secretKey = null; + try { + secretKey = attemptParseSecretKey(keyFile, calculatorProvider, + () -> passphrasePrompt.getPassphrase( + publicKey.getFingerprint(), userKeyboxPath), + publicKey); + } catch (PGPException e) { + throw new PGPException(MessageFormat.format( + BCText.get().gpgFailedToParseSecretKey, + keyFile.toAbsolutePath()), e); } - PBEProtectionRemoverFactory passphraseProvider = p -> { - throw new EncryptedPgpKeyException(); - }; - for (int attempts = 0; attempts < 2; attempts++) { - // Second pass will traverse only the encrypted keys with a real - // passphrase provider. - Iterator<Path> pathIterator = allPaths.iterator(); - while (pathIterator.hasNext()) { - Path keyFile = pathIterator.next(); - try { - PGPSecretKey secretKey = attemptParseSecretKey(keyFile, - calculatorProvider, passphraseProvider, - publicKey); - pathIterator.remove(); - if (secretKey != null) { - if (!secretKey.isSigningKey()) { - throw new PGPException(MessageFormat.format( - BCText.get().gpgNotASigningKey, - signingKey)); - } - return new BouncyCastleGpgKey(secretKey, - userKeyboxPath); - } - } catch (EncryptedPgpKeyException e) { - // Ignore; we'll try again. - } - } - if (attempts > 0 || allPaths.isEmpty()) { - break; + if (secretKey != null) { + if (!secretKey.isSigningKey()) { + throw new PGPException(MessageFormat.format( + BCText.get().gpgNotASigningKey, signingKey)); } - // allPaths contains only the encrypted keys now. - passphraseProvider = new JcePBEProtectionRemoverFactory( - passphrasePrompt.getPassphrase( - publicKey.getFingerprint(), userKeyboxPath)); + clearPrompt = false; + return new BouncyCastleGpgKey(secretKey, userKeyboxPath); } - - passphrasePrompt.clear(); return null; } catch (RuntimeException e) { - passphrasePrompt.clear(); throw e; + } catch (FileNotFoundException | NoSuchFileException e) { + clearPrompt = false; + return null; } catch (IOException e) { - passphrasePrompt.clear(); throw new PGPException(MessageFormat.format( BCText.get().gpgFailedToParseSecretKey, - USER_SECRET_KEY_DIR.toAbsolutePath()), e); + keyFile.toAbsolutePath()), e); + } finally { + if (clearPrompt) { + passphrasePrompt.clear(); + } } } @@ -551,6 +567,11 @@ public class BouncyCastleGpgKeyLocator { * Return the first public key matching the key id ({@link #signingKey}. * * @param pubringFile + * to search + * @param keyId + * to look for, may be null + * @param keySpec + * to look for * * @return the PGP public key, or {@code null} if none found * @throws IOException @@ -558,14 +579,16 @@ public class BouncyCastleGpgKeyLocator { * @throws PGPException * on BouncyCastle errors */ - private PGPPublicKey findPublicKeyInPubring(Path pubringFile) + private static PGPPublicKey findPublicKeyInPubring(Path pubringFile, + String keyId, String keySpec) throws IOException, PGPException { try (InputStream in = newInputStream(pubringFile)) { PGPPublicKeyRingCollection pgpPub = new PGPPublicKeyRingCollection( new BufferedInputStream(in), new JcaKeyFingerprintCalculator()); - String keyId = toFingerprint(signingKey).toLowerCase(Locale.ROOT); + String id = keyId != null ? keyId + : toFingerprint(keySpec).toLowerCase(Locale.ROOT); Iterator<PGPPublicKeyRing> keyrings = pgpPub.getKeyRings(); while (keyrings.hasNext()) { PGPPublicKeyRing keyRing = keyrings.next(); @@ -575,30 +598,33 @@ public class BouncyCastleGpgKeyLocator { // try key id String fingerprint = Hex.toHexString(key.getFingerprint()) .toLowerCase(Locale.ROOT); - if (fingerprint.endsWith(keyId)) { + if (fingerprint.endsWith(id)) { return key; } // try user id Iterator<String> userIDs = key.getUserIDs(); while (userIDs.hasNext()) { String userId = userIDs.next(); - if (containsSigningKey(userId, signingKey)) { + if (containsSigningKey(userId, keySpec)) { return key; } } } } + } catch (FileNotFoundException | NoSuchFileException e) { + // Ignore and return null } return null; } - private PGPPublicKey getPublicKey(KeyBlob blob, byte[] fingerprint) + private static PGPPublicKey getPublicKey(KeyBlob blob, byte[] fingerprint) throws IOException { return ((PublicKeyRingBlob) blob).getPGPPublicKeyRing() .getPublicKey(fingerprint); } - private PGPPublicKey getSigningPublicKey(KeyBlob blob) throws IOException { + private static PGPPublicKey getSigningPublicKey(KeyBlob blob) + throws IOException { PGPPublicKey masterKey = null; Iterator<PGPPublicKey> keys = ((PublicKeyRingBlob) blob) .getPGPPublicKeyRing().getPublicKeys(); @@ -618,7 +644,7 @@ public class BouncyCastleGpgKeyLocator { return masterKey; } - private boolean isSigningKey(PGPPublicKey key) { + private static boolean isSigningKey(PGPPublicKey key) { Iterator signatures = key.getSignatures(); while (signatures.hasNext()) { PGPSignature sig = (PGPSignature) signatures.next(); @@ -630,7 +656,7 @@ public class BouncyCastleGpgKeyLocator { return false; } - private KeyBox readKeyBoxFile(Path keyboxFile) throws IOException, + private static KeyBox readKeyBoxFile(Path keyboxFile) throws IOException, NoSuchAlgorithmException, NoSuchProviderException, NoOpenPgpKeyException { if (keyboxFile.toFile().length() == 0) { 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 index e47f64f1a6..6144195983 100644 --- 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 @@ -17,8 +17,8 @@ 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.CredentialItem.Password; import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.transport.URIish; @@ -31,7 +31,7 @@ import org.eclipse.jgit.transport.URIish; */ class BouncyCastleGpgKeyPassphrasePrompt implements AutoCloseable { - private CharArrayType passphrase; + private Password passphrase; private CredentialsProvider credentialsProvider; @@ -78,8 +78,7 @@ class BouncyCastleGpgKeyPassphrasePrompt implements AutoCloseable { throws PGPException, CanceledException, UnsupportedCredentialItem, URISyntaxException { if (passphrase == null) { - passphrase = new CharArrayType(BCText.get().credentialPassphrase, - true); + passphrase = new Password(BCText.get().credentialPassphrase); } if (credentialsProvider == null) { diff --git a/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSignatureVerifier.java b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSignatureVerifier.java new file mode 100644 index 0000000000..7161895a6b --- /dev/null +++ b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSignatureVerifier.java @@ -0,0 +1,388 @@ +/* + * Copyright (C) 2021, Thomas Wolf <thomas.wolf@paranor.ch> 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.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.Security; +import java.text.MessageFormat; +import java.time.Instant; +import java.util.Arrays; +import java.util.Date; +import java.util.Iterator; +import java.util.Locale; + +import org.bouncycastle.bcpg.sig.IssuerFingerprint; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openpgp.PGPCompressedData; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.openpgp.PGPSignatureList; +import org.bouncycastle.openpgp.PGPSignatureSubpacketVector; +import org.bouncycastle.openpgp.PGPUtil; +import org.bouncycastle.openpgp.jcajce.JcaPGPObjectFactory; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentVerifierBuilderProvider; +import org.bouncycastle.util.encoders.Hex; +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.annotations.Nullable; +import org.eclipse.jgit.api.errors.JGitInternalException; +import org.eclipse.jgit.lib.GpgConfig; +import org.eclipse.jgit.lib.GpgSignatureVerifier; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevObject; +import org.eclipse.jgit.revwalk.RevTag; +import org.eclipse.jgit.util.LRUMap; +import org.eclipse.jgit.util.RawParseUtils; +import org.eclipse.jgit.util.StringUtils; + +/** + * A {@link GpgSignatureVerifier} to verify GPG signatures using BouncyCastle. + */ +public class BouncyCastleGpgSignatureVerifier implements GpgSignatureVerifier { + + private static void registerBouncyCastleProviderIfNecessary() { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + /** + * Creates a new instance and registers the BouncyCastle security provider + * if needed. + */ + public BouncyCastleGpgSignatureVerifier() { + registerBouncyCastleProviderIfNecessary(); + } + + // To support more efficient signature verification of multiple objects we + // cache public keys once found in a LRU cache. + + private static final Object NO_KEY = new Object(); + + private LRUMap<String, Object> byFingerprint = new LRUMap<>(16, 200); + + private LRUMap<String, Object> bySigner = new LRUMap<>(16, 200); + + @Override + public String getName() { + return "bc"; //$NON-NLS-1$ + } + + @Override + @Nullable + public SignatureVerification verifySignature(@NonNull RevObject object, + @NonNull GpgConfig config) throws IOException { + if (object instanceof RevCommit) { + RevCommit commit = (RevCommit) object; + byte[] signatureData = commit.getRawGpgSignature(); + if (signatureData == null) { + return null; + } + byte[] raw = commit.getRawBuffer(); + // Now remove the GPG signature + byte[] header = { 'g', 'p', 'g', 's', 'i', 'g' }; + int start = RawParseUtils.headerStart(header, raw, 0); + if (start < 0) { + return null; + } + int end = RawParseUtils.headerEnd(raw, start); + // start is at the beginning of the header's content + start -= header.length + 1; + // end is on the terminating LF; we need to skip that, too + if (end < raw.length) { + end++; + } + byte[] data = new byte[raw.length - (end - start)]; + System.arraycopy(raw, 0, data, 0, start); + System.arraycopy(raw, end, data, start, raw.length - end); + return verify(data, signatureData); + } else if (object instanceof RevTag) { + RevTag tag = (RevTag) object; + byte[] signatureData = tag.getRawGpgSignature(); + if (signatureData == null) { + return null; + } + byte[] raw = tag.getRawBuffer(); + // The signature is just tacked onto the end of the message, which + // is last in the buffer. + byte[] data = Arrays.copyOfRange(raw, 0, + raw.length - signatureData.length); + return verify(data, signatureData); + } + return null; + } + + static PGPSignature parseSignature(InputStream in) + throws IOException, PGPException { + try (InputStream sigIn = PGPUtil.getDecoderStream(in)) { + JcaPGPObjectFactory pgpFactory = new JcaPGPObjectFactory(sigIn); + Object obj = pgpFactory.nextObject(); + if (obj instanceof PGPCompressedData) { + obj = new JcaPGPObjectFactory( + ((PGPCompressedData) obj).getDataStream()).nextObject(); + } + if (obj instanceof PGPSignatureList) { + return ((PGPSignatureList) obj).get(0); + } + return null; + } + } + + @Override + public SignatureVerification verify(byte[] data, byte[] signatureData) + throws IOException { + PGPSignature signature = null; + String fingerprint = null; + String signer = null; + String keyId = null; + try (InputStream sigIn = new ByteArrayInputStream(signatureData)) { + signature = parseSignature(sigIn); + if (signature != null) { + // Try to figure out something to find the public key with. + if (signature.hasSubpackets()) { + PGPSignatureSubpacketVector packets = signature + .getHashedSubPackets(); + IssuerFingerprint fingerprintPacket = packets + .getIssuerFingerprint(); + if (fingerprintPacket != null) { + fingerprint = Hex + .toHexString(fingerprintPacket.getFingerprint()) + .toLowerCase(Locale.ROOT); + } + signer = packets.getSignerUserID(); + if (signer != null) { + signer = BouncyCastleGpgSigner.extractSignerId(signer); + } + } + keyId = Long.toUnsignedString(signature.getKeyID(), 16) + .toLowerCase(Locale.ROOT); + } else { + throw new JGitInternalException(BCText.get().nonSignatureError); + } + } catch (PGPException e) { + throw new JGitInternalException(BCText.get().signatureParseError, + e); + } + Date signatureCreatedAt = signature.getCreationTime(); + if (fingerprint == null && signer == null && keyId == null) { + return new VerificationResult(signatureCreatedAt, null, null, null, + false, false, TrustLevel.UNKNOWN, + BCText.get().signatureNoKeyInfo); + } + if (fingerprint != null && keyId != null + && !fingerprint.endsWith(keyId)) { + return new VerificationResult(signatureCreatedAt, signer, fingerprint, + null, false, false, TrustLevel.UNKNOWN, + MessageFormat.format(BCText.get().signatureInconsistent, + keyId, fingerprint)); + } + if (fingerprint == null && keyId != null) { + fingerprint = keyId; + } + // Try to find the public key + String keySpec = '<' + signer + '>'; + Object cached = null; + PGPPublicKey publicKey = null; + try { + cached = byFingerprint.get(fingerprint); + if (cached != null) { + if (cached instanceof PGPPublicKey) { + publicKey = (PGPPublicKey) cached; + } + } else if (!StringUtils.isEmptyOrNull(signer)) { + cached = bySigner.get(signer); + if (cached != null) { + if (cached instanceof PGPPublicKey) { + publicKey = (PGPPublicKey) cached; + } + } + } + if (cached == null) { + publicKey = BouncyCastleGpgKeyLocator.findPublicKey(fingerprint, + keySpec); + } + } catch (IOException | PGPException e) { + throw new JGitInternalException( + BCText.get().signatureKeyLookupError, e); + } + if (publicKey == null) { + if (cached == null) { + byFingerprint.put(fingerprint, NO_KEY); + byFingerprint.put(keyId, NO_KEY); + if (signer != null) { + bySigner.put(signer, NO_KEY); + } + } + return new VerificationResult(signatureCreatedAt, signer, + fingerprint, null, false, false, TrustLevel.UNKNOWN, + BCText.get().signatureNoPublicKey); + } + if (cached == null) { + byFingerprint.put(fingerprint, publicKey); + byFingerprint.put(keyId, publicKey); + if (signer != null) { + bySigner.put(signer, publicKey); + } + } + String user = null; + Iterator<String> userIds = publicKey.getUserIDs(); + if (!StringUtils.isEmptyOrNull(signer)) { + while (userIds.hasNext()) { + String userId = userIds.next(); + if (BouncyCastleGpgKeyLocator.containsSigningKey(userId, + keySpec)) { + user = userId; + break; + } + } + } + if (user == null) { + userIds = publicKey.getUserIDs(); + if (userIds.hasNext()) { + user = userIds.next(); + } + } + boolean expired = false; + long validFor = publicKey.getValidSeconds(); + if (validFor > 0 && signatureCreatedAt != null) { + Instant expiredAt = publicKey.getCreationTime().toInstant() + .plusSeconds(validFor); + expired = expiredAt.isBefore(signatureCreatedAt.toInstant()); + } + // Trust data is not defined in OpenPGP; the format is implementation + // specific. We don't use the GPG trustdb but simply the trust packet + // on the public key, if present. Even if present, it may or may not + // be set. + byte[] trustData = publicKey.getTrustData(); + TrustLevel trust = parseGpgTrustPacket(trustData); + boolean verified = false; + try { + signature.init( + new JcaPGPContentVerifierBuilderProvider() + .setProvider(BouncyCastleProvider.PROVIDER_NAME), + publicKey); + signature.update(data); + verified = signature.verify(); + } catch (PGPException e) { + throw new JGitInternalException( + BCText.get().signatureVerificationError, e); + } + return new VerificationResult(signatureCreatedAt, signer, fingerprint, user, + verified, expired, trust, null); + } + + private TrustLevel parseGpgTrustPacket(byte[] packet) { + if (packet == null || packet.length < 6) { + // A GPG trust packet has at least 6 bytes. + return TrustLevel.UNKNOWN; + } + if (packet[2] != 'g' || packet[3] != 'p' || packet[4] != 'g') { + // Not a GPG trust packet + return TrustLevel.UNKNOWN; + } + int trust = packet[0] & 0x0F; + switch (trust) { + case 0: // No determined/set + case 1: // Trust expired; i.e., calculation outdated or key expired + case 2: // Undefined: not enough information to set + return TrustLevel.UNKNOWN; + case 3: + return TrustLevel.NEVER; + case 4: + return TrustLevel.MARGINAL; + case 5: + return TrustLevel.FULL; + case 6: + return TrustLevel.ULTIMATE; + default: + return TrustLevel.UNKNOWN; + } + } + + @Override + public void clear() { + byFingerprint.clear(); + bySigner.clear(); + } + + private static class VerificationResult implements SignatureVerification { + + private final Date creationDate; + + private final String signer; + + private final String keyUser; + + private final String fingerprint; + + private final boolean verified; + + private final boolean expired; + + private final @NonNull TrustLevel trustLevel; + + private final String message; + + public VerificationResult(Date creationDate, String signer, + String fingerprint, String user, boolean verified, + boolean expired, @NonNull TrustLevel trust, String message) { + this.creationDate = creationDate; + this.signer = signer; + this.fingerprint = fingerprint; + this.keyUser = user; + this.verified = verified; + this.expired = expired; + this.trustLevel = trust; + this.message = message; + } + + @Override + public Date getCreationDate() { + return creationDate; + } + + @Override + public String getSigner() { + return signer; + } + + @Override + public String getKeyUser() { + return keyUser; + } + + @Override + public String getKeyFingerprint() { + return fingerprint; + } + + @Override + public boolean isExpired() { + return expired; + } + + @Override + public TrustLevel getTrustLevel() { + return trustLevel; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public boolean getVerified() { + return verified; + } + } +} diff --git a/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSignatureVerifierFactory.java b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSignatureVerifierFactory.java new file mode 100644 index 0000000000..ae82b758a6 --- /dev/null +++ b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSignatureVerifierFactory.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2021, Thomas Wolf <thomas.wolf@paranor.ch> 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 org.eclipse.jgit.lib.GpgSignatureVerifier; +import org.eclipse.jgit.lib.GpgSignatureVerifierFactory; + +/** + * A {@link GpgSignatureVerifierFactory} that creates + * {@link GpgSignatureVerifier} instances that verify GPG signatures using + * BouncyCastle and that do cache public keys. + */ +public final class BouncyCastleGpgSignatureVerifierFactory + extends GpgSignatureVerifierFactory { + + @Override + public GpgSignatureVerifier getVerifier() { + return new BouncyCastleGpgSignatureVerifier(); + } + +} 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 index ea159c547d..211bd7bd20 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright (C) 2018, 2020, Salesforce and others + * 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 @@ -34,18 +34,25 @@ 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.api.errors.UnsupportedSigningFormatException; import org.eclipse.jgit.errors.UnsupportedCredentialItem; +import org.eclipse.jgit.internal.JGitText; import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.GpgConfig; +import org.eclipse.jgit.lib.GpgObjectSigner; import org.eclipse.jgit.lib.GpgSignature; import org.eclipse.jgit.lib.GpgSigner; +import org.eclipse.jgit.lib.ObjectBuilder; import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.GpgConfig.GpgFormat; import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.util.StringUtils; /** - * GPG Signer using BouncyCastle library + * GPG Signer using the BouncyCastle library. */ -public class BouncyCastleGpgSigner extends GpgSigner { +public class BouncyCastleGpgSigner extends GpgSigner + implements GpgObjectSigner { private static void registerBouncyCastleProviderIfNecessary() { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { @@ -67,13 +74,32 @@ public class BouncyCastleGpgSigner extends GpgSigner { public boolean canLocateSigningKey(@Nullable String gpgSigningKey, PersonIdent committer, CredentialsProvider credentialsProvider) throws CanceledException { + try { + return canLocateSigningKey(gpgSigningKey, committer, + credentialsProvider, null); + } catch (UnsupportedSigningFormatException e) { + // Cannot occur with a null config + return false; + } + } + + @Override + public boolean canLocateSigningKey(@Nullable String gpgSigningKey, + PersonIdent committer, CredentialsProvider credentialsProvider, + GpgConfig config) + throws CanceledException, UnsupportedSigningFormatException { + if (config != null && config.getKeyFormat() != GpgFormat.OPENPGP) { + throw new UnsupportedSigningFormatException( + JGitText.get().onlyOpenPgpSupportedForSigning); + } try (BouncyCastleGpgKeyPassphrasePrompt passphrasePrompt = new BouncyCastleGpgKeyPassphrasePrompt( credentialsProvider)) { BouncyCastleGpgKey gpgKey = locateSigningKey(gpgSigningKey, committer, passphrasePrompt); return gpgKey != null; - } catch (PGPException | IOException | NoSuchAlgorithmException - | NoSuchProviderException | URISyntaxException e) { + } catch (CanceledException e) { + throw e; + } catch (Exception e) { return false; } } @@ -98,10 +124,28 @@ public class BouncyCastleGpgSigner extends GpgSigner { public void sign(@NonNull CommitBuilder commit, @Nullable String gpgSigningKey, @NonNull PersonIdent committer, CredentialsProvider credentialsProvider) throws CanceledException { + try { + signObject(commit, gpgSigningKey, committer, credentialsProvider, + null); + } catch (UnsupportedSigningFormatException e) { + // Cannot occur with a null config + } + } + + @Override + public void signObject(@NonNull ObjectBuilder object, + @Nullable String gpgSigningKey, @NonNull PersonIdent committer, + CredentialsProvider credentialsProvider, GpgConfig config) + throws CanceledException, UnsupportedSigningFormatException { + if (config != null && config.getKeyFormat() != GpgFormat.OPENPGP) { + throw new UnsupportedSigningFormatException( + JGitText.get().onlyOpenPgpSupportedForSigning); + } try (BouncyCastleGpgKeyPassphrasePrompt passphrasePrompt = new BouncyCastleGpgKeyPassphrasePrompt( credentialsProvider)) { BouncyCastleGpgKey gpgKey = locateSigningKey(gpgSigningKey, - committer, passphrasePrompt); + committer, + passphrasePrompt); PGPSecretKey secretKey = gpgKey.getSecretKey(); if (secretKey == null) { throw new JGitInternalException( @@ -158,17 +202,17 @@ public class BouncyCastleGpgSigner extends GpgSigner { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); try (BCPGOutputStream out = new BCPGOutputStream( new ArmoredOutputStream(buffer))) { - signatureGenerator.update(commit.build()); + signatureGenerator.update(object.build()); signatureGenerator.generate().encode(out); } - commit.setGpgSignature(new GpgSignature(buffer.toByteArray())); + object.setGpgSignature(new GpgSignature(buffer.toByteArray())); } catch (PGPException | IOException | NoSuchAlgorithmException | NoSuchProviderException | URISyntaxException e) { throw new JGitInternalException(e.getMessage(), e); } } - private String extractSignerId(String pgpUserId) { + static String extractSignerId(String pgpUserId) { int from = pgpUserId.indexOf('<'); if (from >= 0) { int to = pgpUserId.indexOf('>', from + 1); diff --git a/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/keys/KeyGrip.java b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/keys/KeyGrip.java new file mode 100644 index 0000000000..b1d4446010 --- /dev/null +++ b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/keys/KeyGrip.java @@ -0,0 +1,322 @@ +/* + * Copyright (C) 2021, Thomas Wolf <thomas.wolf@paranor.ch> 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.keys; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.text.MessageFormat; +import java.util.Arrays; + +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.cryptlib.CryptlibObjectIdentifiers; +import org.bouncycastle.asn1.x9.ECNamedCurveTable; +import org.bouncycastle.asn1.x9.X9ECParameters; +import org.bouncycastle.bcpg.DSAPublicBCPGKey; +import org.bouncycastle.bcpg.ECPublicBCPGKey; +import org.bouncycastle.bcpg.ElGamalPublicBCPGKey; +import org.bouncycastle.bcpg.PublicKeyAlgorithmTags; +import org.bouncycastle.bcpg.RSAPublicBCPGKey; +import org.bouncycastle.crypto.ec.CustomNamedCurves; +import org.bouncycastle.math.ec.ECAlgorithms; +import org.bouncycastle.math.field.FiniteField; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.util.encoders.Hex; +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.gpg.bc.internal.BCText; +import org.eclipse.jgit.util.sha1.SHA1; + +/** + * Utilities to compute the <em>keygrip</em> of a key. A keygrip is a SHA1 hash + * over the public key parameters and is used internally by the gpg-agent to + * find the secret key belonging to a public key: the secret key is stored in a + * file under ~/.gnupg/private-keys-v1.d/ with a name "<keygrip>.key". While + * this storage organization is an implementation detail of GPG, the way + * keygrips are computed is not; they are computed by libgcrypt and their + * definition is stable. + */ +public final class KeyGrip { + + // Some OIDs apparently unknown to BouncyCastle. + + private static String OID_OPENPGP_ED25519 = "1.3.6.1.4.1.11591.15.1"; //$NON-NLS-1$ + + private static String OID_RFC8410_CURVE25519 = "1.3.101.110"; //$NON-NLS-1$ + + private static String OID_RFC8410_ED25519 = "1.3.101.112"; //$NON-NLS-1$ + + private KeyGrip() { + // No instantiation + } + + /** + * Computes the keygrip for a {@link PGPPublicKey}. + * + * @param publicKey + * to get the keygrip of + * @return the keygrip + * @throws PGPException + * if an unknown key type is encountered. + */ + @NonNull + public static byte[] getKeyGrip(PGPPublicKey publicKey) + throws PGPException { + SHA1 grip = SHA1.newInstance(); + grip.setDetectCollision(false); + + switch (publicKey.getAlgorithm()) { + case PublicKeyAlgorithmTags.RSA_GENERAL: + case PublicKeyAlgorithmTags.RSA_ENCRYPT: + case PublicKeyAlgorithmTags.RSA_SIGN: + BigInteger modulus = ((RSAPublicBCPGKey) publicKey + .getPublicKeyPacket().getKey()).getModulus(); + hash(grip, modulus.toByteArray()); + break; + case PublicKeyAlgorithmTags.DSA: + DSAPublicBCPGKey dsa = (DSAPublicBCPGKey) publicKey + .getPublicKeyPacket().getKey(); + hash(grip, dsa.getP().toByteArray(), 'p', true); + hash(grip, dsa.getQ().toByteArray(), 'q', true); + hash(grip, dsa.getG().toByteArray(), 'g', true); + hash(grip, dsa.getY().toByteArray(), 'y', true); + break; + case PublicKeyAlgorithmTags.ELGAMAL_GENERAL: + case PublicKeyAlgorithmTags.ELGAMAL_ENCRYPT: + ElGamalPublicBCPGKey eg = (ElGamalPublicBCPGKey) publicKey + .getPublicKeyPacket().getKey(); + hash(grip, eg.getP().toByteArray(), 'p', true); + hash(grip, eg.getG().toByteArray(), 'g', true); + hash(grip, eg.getY().toByteArray(), 'y', true); + break; + case PublicKeyAlgorithmTags.ECDH: + case PublicKeyAlgorithmTags.ECDSA: + case PublicKeyAlgorithmTags.EDDSA: + ECPublicBCPGKey ec = (ECPublicBCPGKey) publicKey + .getPublicKeyPacket().getKey(); + ASN1ObjectIdentifier curveOID = ec.getCurveOID(); + // BC doesn't know these OIDs. + if (OID_OPENPGP_ED25519.equals(curveOID.getId()) + || OID_RFC8410_ED25519.equals(curveOID.getId())) { + return hashEd25519(grip, ec.getEncodedPoint()); + } else if (CryptlibObjectIdentifiers.curvey25519.equals(curveOID) + || OID_RFC8410_CURVE25519.equals(curveOID.getId())) { + // curvey25519 actually is the OpenPGP OID for Curve25519 and is + // known to BC, but the parameters are for the short Weierstrass + // form. See https://github.com/bcgit/bc-java/issues/399 . + // libgcrypt uses Montgomery form. + return hashCurve25519(grip, ec.getEncodedPoint()); + } + X9ECParameters params = getX9Parameters(curveOID); + if (params == null) { + throw new PGPException(MessageFormat + .format(BCText.get().unknownCurve, curveOID.getId())); + } + // Need to write p, a, b, g, n, q + BigInteger q = ec.getEncodedPoint(); + byte[] g = params.getG().getEncoded(false); + BigInteger a = params.getCurve().getA().toBigInteger(); + BigInteger b = params.getCurve().getB().toBigInteger(); + BigInteger n = params.getN(); + BigInteger p = null; + FiniteField field = params.getCurve().getField(); + if (ECAlgorithms.isFpField(field)) { + p = field.getCharacteristic(); + } + if (p == null) { + // Don't know... + throw new PGPException(MessageFormat.format( + BCText.get().unknownCurveParameters, curveOID.getId())); + } + hash(grip, p.toByteArray(), 'p', false); + hash(grip, a.toByteArray(), 'a', false); + hash(grip, b.toByteArray(), 'b', false); + hash(grip, g, 'g', false); + hash(grip, n.toByteArray(), 'n', false); + if (publicKey.getAlgorithm() == PublicKeyAlgorithmTags.EDDSA) { + hashQ25519(grip, q); + } else { + hash(grip, q.toByteArray(), 'q', false); + } + break; + default: + throw new PGPException( + MessageFormat.format(BCText.get().unknownKeyType, + Integer.toString(publicKey.getAlgorithm()))); + } + return grip.digest(); + } + + private static void hash(SHA1 grip, byte[] data) { + // Need to skip leading zero bytes + int i = 0; + while (i < data.length && data[i] == 0) { + i++; + } + int length = data.length - i; + if (i < data.length) { + if ((data[i] & 0x80) != 0) { + grip.update((byte) 0); + } + grip.update(data, i, length); + } + } + + private static void hash(SHA1 grip, byte[] data, char id, boolean zeroPad) { + // Need to skip leading zero bytes + int i = 0; + while (i < data.length && data[i] == 0) { + i++; + } + int length = data.length - i; + boolean addZero = false; + if (i < data.length && zeroPad && (data[i] & 0x80) != 0) { + addZero = true; + } + // libgcrypt includes an SExp in the hash + String prefix = "(1:" + id + (addZero ? length + 1 : length) + ':'; //$NON-NLS-1$ + grip.update(prefix.getBytes(StandardCharsets.US_ASCII)); + // For some items, gcrypt prepends a zero byte if the high bit is set + if (addZero) { + grip.update((byte) 0); + } + if (i < data.length) { + grip.update(data, i, length); + } + grip.update((byte) ')'); + } + + private static void hashQ25519(SHA1 grip, BigInteger q) + throws PGPException { + byte[] data = q.toByteArray(); + switch (data[0]) { + case 0x04: + if (data.length != 65) { + throw new PGPException(MessageFormat.format( + BCText.get().corrupt25519Key, Hex.toHexString(data))); + } + // Uncompressed: should not occur with ed25519 or curve25519 + throw new PGPException(MessageFormat.format( + BCText.get().uncompressed25519Key, Hex.toHexString(data))); + case 0x40: + if (data.length != 33) { + throw new PGPException(MessageFormat.format( + BCText.get().corrupt25519Key, Hex.toHexString(data))); + } + // Compressed; normal case. Skip prefix. + hash(grip, Arrays.copyOfRange(data, 1, data.length), 'q', false); + break; + default: + if (data.length != 32) { + throw new PGPException(MessageFormat.format( + BCText.get().corrupt25519Key, Hex.toHexString(data))); + } + // Compressed format without prefix. Should not occur? + hash(grip, data, 'q', false); + break; + } + } + + /** + * Computes the keygrip for an ed25519 public key. + * <p> + * Package-visible for tests only. + * </p> + * + * @param grip + * initialized {@link SHA1} + * @param q + * the public key's EC point + * @return the keygrip + * @throws PGPException + * if q indicates uncompressed format + */ + @SuppressWarnings("nls") + static byte[] hashEd25519(SHA1 grip, BigInteger q) throws PGPException { + // For the values, see RFC 7748: https://tools.ietf.org/html/rfc7748 + // p = 2^255 - 19 + hash(grip, Hex.decodeStrict( + "7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFED"), + 'p', false); + // Field: a = 1 + hash(grip, new byte[] { 0x01 }, 'a', false); + // Field: b = 121665/121666 (mod p) + // See Berstein et.al., "Twisted Edwards Curves", + // https://doi.org/10.1007/978-3-540-68164-9_26 + hash(grip, Hex.decodeStrict( + "2DFC9311D490018C7338BF8688861767FF8FF5B2BEBE27548A14B235ECA6874A"), + 'b', false); + // Generator point with affine X,Y + // @formatter:off + // X(P) = 15112221349535400772501151409588531511454012693041857206046113283949847762202 + // Y(P) = 46316835694926478169428394003475163141307993866256225615783033603165251855960 + // the "04" signifies uncompressed format. + // @formatter:on + hash(grip, Hex.decodeStrict("04" + + "216936D3CD6E53FEC0A4E231FDD6DC5C692CC7609525A7B2C9562D608F25D51A" + + "6666666666666666666666666666666666666666666666666666666666666658"), + 'g', false); + // order = 2^252 + 0x14def9dea2f79cd65812631a5cf5d3ed + hash(grip, Hex.decodeStrict( + "1000000000000000000000000000000014DEF9DEA2F79CD65812631A5CF5D3ED"), + 'n', false); + hashQ25519(grip, q); + return grip.digest(); + } + + /** + * Computes the keygrip for a curve25519 public key. + * <p> + * Package-visible for tests only. + * </p> + * + * @param grip + * initialized {@link SHA1} + * @param q + * the public key's EC point + * @return the keygrip + * @throws PGPException + * if q indicates uncompressed format + */ + @SuppressWarnings("nls") + static byte[] hashCurve25519(SHA1 grip, BigInteger q) throws PGPException { + hash(grip, Hex.decodeStrict( + "7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFED"), + 'p', false); + // Unclear: RFC 7748 says A = 486662. This value here is (A-2)/4 = + // 121665. Compare ecc-curves.c in libgcrypt: + // https://github.com/gpg/libgcrypt/blob/361a058/cipher/ecc-curves.c#L146 + hash(grip, new byte[] { 0x01, (byte) 0xDB, 0x41 }, 'a', false); + hash(grip, new byte[] { 0x01 }, 'b', false); + // libgcrypt uses the old g.y value before the erratum to RFC 7748 for + // the keygrip. The new value would be + // 5F51E65E475F794B1FE122D388B72EB36DC2B28192839E4DD6163A5D81312C14. See + // https://www.rfc-editor.org/errata/eid4730 and + // https://github.com/gpg/libgcrypt/commit/f67b6492e0b0 + hash(grip, Hex.decodeStrict("04" + + "0000000000000000000000000000000000000000000000000000000000000009" + + "20AE19A1B8A086B4E01EDD2C7748D14C923D4D7E6D7C61B229E9C5A27ECED3D9"), + 'g', false); + hash(grip, Hex.decodeStrict( + "1000000000000000000000000000000014DEF9DEA2F79CD65812631A5CF5D3ED"), + 'n', false); + hashQ25519(grip, q); + return grip.digest(); + } + + private static X9ECParameters getX9Parameters( + ASN1ObjectIdentifier curveOID) { + X9ECParameters params = CustomNamedCurves.getByOID(curveOID); + if (params == null) { + params = ECNamedCurveTable.getByOID(curveOID); + } + return params; + } + +} diff --git a/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/keys/OCBPBEProtectionRemoverFactory.java b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/keys/OCBPBEProtectionRemoverFactory.java new file mode 100644 index 0000000000..68f8a45555 --- /dev/null +++ b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/keys/OCBPBEProtectionRemoverFactory.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2021 Thomas Wolf <thomas.wolf@paranor.ch> 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.keys; + +import java.security.NoSuchAlgorithmException; +import java.text.MessageFormat; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPUtil; +import org.bouncycastle.openpgp.operator.PBEProtectionRemoverFactory; +import org.bouncycastle.openpgp.operator.PBESecretKeyDecryptor; +import org.bouncycastle.openpgp.operator.PGPDigestCalculatorProvider; +import org.bouncycastle.util.Arrays; +import org.eclipse.jgit.gpg.bc.internal.BCText; + +/** + * A {@link PBEProtectionRemoverFactory} using AES/OCB/NoPadding for decryption. + * It accepts an AAD in the factory's constructor, so the factory can be used to + * create a {@link PBESecretKeyDecryptor} only for a particular input. + * <p> + * For JGit's needs, this is sufficient, but for a general upstream + * implementation that limitation might not be acceptable. + * </p> + */ +class OCBPBEProtectionRemoverFactory + implements PBEProtectionRemoverFactory { + + private final PGPDigestCalculatorProvider calculatorProvider; + + private final char[] passphrase; + + private final byte[] aad; + + /** + * Creates a new factory instance with the given parameters. + * <p> + * Because the AAD is given at factory level, the {@link PBESecretKeyDecryptor}s + * created by the factory can be used to decrypt only a particular input + * matching this AAD. + * </p> + * + * @param passphrase to use for secret key derivation + * @param calculatorProvider for computing digests + * @param aad for the OCB decryption + */ + OCBPBEProtectionRemoverFactory(char[] passphrase, + PGPDigestCalculatorProvider calculatorProvider, byte[] aad) { + this.calculatorProvider = calculatorProvider; + this.passphrase = passphrase; + this.aad = aad; + } + + @Override + public PBESecretKeyDecryptor createDecryptor(String protection) + throws PGPException { + return new PBESecretKeyDecryptor(passphrase, calculatorProvider) { + + @Override + public byte[] recoverKeyData(int encAlgorithm, byte[] key, + byte[] iv, byte[] encrypted, int encryptedOffset, + int encryptedLength) throws PGPException { + String algorithmName = PGPUtil + .getSymmetricCipherName(encAlgorithm); + byte[] decrypted = null; + try { + Cipher c = Cipher + .getInstance(algorithmName + "/OCB/NoPadding"); //$NON-NLS-1$ + SecretKey secretKey = new SecretKeySpec(key, algorithmName); + c.init(Cipher.DECRYPT_MODE, secretKey, + new IvParameterSpec(iv)); + c.updateAAD(aad); + decrypted = new byte[c.getOutputSize(encryptedLength)]; + int decryptedLength = c.update(encrypted, encryptedOffset, + encryptedLength, decrypted); + // doFinal() for OCB will check the MAC and throw an + // exception if it doesn't match + decryptedLength += c.doFinal(decrypted, decryptedLength); + if (decryptedLength != decrypted.length) { + throw new PGPException(MessageFormat.format( + BCText.get().cryptWrongDecryptedLength, + Integer.valueOf(decryptedLength), + Integer.valueOf(decrypted.length))); + } + byte[] result = decrypted; + decrypted = null; // Don't clear in finally + return result; + } catch (NoClassDefFoundError e) { + String msg = MessageFormat.format( + BCText.get().gpgNoSuchAlgorithm, + algorithmName + "/OCB"); //$NON-NLS-1$ + throw new PGPException(msg, + new NoSuchAlgorithmException(msg, e)); + } catch (PGPException e) { + throw e; + } catch (Exception e) { + throw new PGPException( + MessageFormat.format(BCText.get().cryptCipherError, + e.getLocalizedMessage()), + e); + } finally { + if (decrypted != null) { + // Prevent halfway decrypted data leaking. + Arrays.fill(decrypted, (byte) 0); + } + } + } + }; + } +}
\ No newline at end of file diff --git a/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/keys/SExprParser.java b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/keys/SExprParser.java new file mode 100644 index 0000000000..a9bb22c780 --- /dev/null +++ b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/keys/SExprParser.java @@ -0,0 +1,826 @@ +/* + * Copyright (c) 2000-2021 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) + * <p> + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without restriction, + *including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * </p> + * <p> + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * </p> + * <p> + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR + * PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * </p> + */ +package org.eclipse.jgit.gpg.bc.internal.keys; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.math.BigInteger; +import java.util.Date; + +import org.bouncycastle.asn1.x9.ECNamedCurveTable; +import org.bouncycastle.bcpg.DSAPublicBCPGKey; +import org.bouncycastle.bcpg.DSASecretBCPGKey; +import org.bouncycastle.bcpg.ECDSAPublicBCPGKey; +import org.bouncycastle.bcpg.ECPublicBCPGKey; +import org.bouncycastle.bcpg.ECSecretBCPGKey; +import org.bouncycastle.bcpg.ElGamalPublicBCPGKey; +import org.bouncycastle.bcpg.ElGamalSecretBCPGKey; +import org.bouncycastle.bcpg.HashAlgorithmTags; +import org.bouncycastle.bcpg.PublicKeyAlgorithmTags; +import org.bouncycastle.bcpg.PublicKeyPacket; +import org.bouncycastle.bcpg.RSAPublicBCPGKey; +import org.bouncycastle.bcpg.RSASecretBCPGKey; +import org.bouncycastle.bcpg.S2K; +import org.bouncycastle.bcpg.SecretKeyPacket; +import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPSecretKey; +import org.bouncycastle.openpgp.operator.KeyFingerPrintCalculator; +import org.bouncycastle.openpgp.operator.PBEProtectionRemoverFactory; +import org.bouncycastle.openpgp.operator.PBESecretKeyDecryptor; +import org.bouncycastle.openpgp.operator.PGPDigestCalculator; +import org.bouncycastle.openpgp.operator.PGPDigestCalculatorProvider; +import org.bouncycastle.util.Arrays; +import org.bouncycastle.util.Strings; + +/** + * A parser for secret keys stored in s-expressions. Original BouncyCastle code + * modified by the JGit team to: + * <ul> + * <li>handle unencrypted DSA, EC, and ElGamal keys (upstream only handles + * unencrypted RSA), and</li> + * <li>handle secret keys using AES/OCB as encryption (those don't have a + * hash).</li> + * </ul> + */ +@SuppressWarnings("nls") +public class SExprParser { + private final PGPDigestCalculatorProvider digestProvider; + + /** + * Base constructor. + * + * @param digestProvider + * a provider for digest calculations. Used to confirm key + * protection hashes. + */ + public SExprParser(PGPDigestCalculatorProvider digestProvider) { + this.digestProvider = digestProvider; + } + + /** + * Parse a secret key from one of the GPG S expression keys associating it + * with the passed in public key. + * + * @param inputStream + * to read from + * @param keyProtectionRemoverFactory + * for decrypting encrypted keys + * @param pubKey + * the private key should belong to + * + * @return a secret key object. + * @throws IOException + * @throws PGPException + */ + public PGPSecretKey parseSecretKey(InputStream inputStream, + PBEProtectionRemoverFactory keyProtectionRemoverFactory, + PGPPublicKey pubKey) throws IOException, PGPException { + SXprUtils.skipOpenParenthesis(inputStream); + + String type; + + type = SXprUtils.readString(inputStream, inputStream.read()); + if (type.equals("protected-private-key") + || type.equals("private-key")) { + SXprUtils.skipOpenParenthesis(inputStream); + + String keyType = SXprUtils.readString(inputStream, + inputStream.read()); + if (keyType.equals("ecc")) { + SXprUtils.skipOpenParenthesis(inputStream); + + String curveID = SXprUtils.readString(inputStream, + inputStream.read()); + String curveName = SXprUtils.readString(inputStream, + inputStream.read()); + + SXprUtils.skipCloseParenthesis(inputStream); + + byte[] qVal; + + SXprUtils.skipOpenParenthesis(inputStream); + + type = SXprUtils.readString(inputStream, inputStream.read()); + if (type.equals("q")) { + qVal = SXprUtils.readBytes(inputStream, inputStream.read()); + } else { + throw new PGPException("no q value found"); + } + + SXprUtils.skipCloseParenthesis(inputStream); + + BigInteger d = processECSecretKey(inputStream, curveID, + curveName, qVal, keyProtectionRemoverFactory); + + if (curveName.startsWith("NIST ")) { + curveName = curveName.substring("NIST ".length()); + } + + ECPublicBCPGKey basePubKey = new ECDSAPublicBCPGKey( + ECNamedCurveTable.getOID(curveName), + new BigInteger(1, qVal)); + ECPublicBCPGKey assocPubKey = (ECPublicBCPGKey) pubKey + .getPublicKeyPacket().getKey(); + if (!basePubKey.getCurveOID().equals(assocPubKey.getCurveOID()) + || !basePubKey.getEncodedPoint() + .equals(assocPubKey.getEncodedPoint())) { + throw new PGPException( + "passed in public key does not match secret key"); + } + + return new PGPSecretKey( + new SecretKeyPacket(pubKey.getPublicKeyPacket(), + SymmetricKeyAlgorithmTags.NULL, null, null, + new ECSecretBCPGKey(d).getEncoded()), + pubKey); + } else if (keyType.equals("dsa")) { + BigInteger p = readBigInteger("p", inputStream); + BigInteger q = readBigInteger("q", inputStream); + BigInteger g = readBigInteger("g", inputStream); + + BigInteger y = readBigInteger("y", inputStream); + + BigInteger x = processDSASecretKey(inputStream, p, q, g, y, + keyProtectionRemoverFactory); + + DSAPublicBCPGKey basePubKey = new DSAPublicBCPGKey(p, q, g, y); + DSAPublicBCPGKey assocPubKey = (DSAPublicBCPGKey) pubKey + .getPublicKeyPacket().getKey(); + if (!basePubKey.getP().equals(assocPubKey.getP()) + || !basePubKey.getQ().equals(assocPubKey.getQ()) + || !basePubKey.getG().equals(assocPubKey.getG()) + || !basePubKey.getY().equals(assocPubKey.getY())) { + throw new PGPException( + "passed in public key does not match secret key"); + } + return new PGPSecretKey( + new SecretKeyPacket(pubKey.getPublicKeyPacket(), + SymmetricKeyAlgorithmTags.NULL, null, null, + new DSASecretBCPGKey(x).getEncoded()), + pubKey); + } else if (keyType.equals("elg")) { + BigInteger p = readBigInteger("p", inputStream); + BigInteger g = readBigInteger("g", inputStream); + + BigInteger y = readBigInteger("y", inputStream); + + BigInteger x = processElGamalSecretKey(inputStream, p, g, y, + keyProtectionRemoverFactory); + + ElGamalPublicBCPGKey basePubKey = new ElGamalPublicBCPGKey(p, g, + y); + ElGamalPublicBCPGKey assocPubKey = (ElGamalPublicBCPGKey) pubKey + .getPublicKeyPacket().getKey(); + if (!basePubKey.getP().equals(assocPubKey.getP()) + || !basePubKey.getG().equals(assocPubKey.getG()) + || !basePubKey.getY().equals(assocPubKey.getY())) { + throw new PGPException( + "passed in public key does not match secret key"); + } + + return new PGPSecretKey( + new SecretKeyPacket(pubKey.getPublicKeyPacket(), + SymmetricKeyAlgorithmTags.NULL, null, null, + new ElGamalSecretBCPGKey(x).getEncoded()), + pubKey); + } else if (keyType.equals("rsa")) { + BigInteger n = readBigInteger("n", inputStream); + BigInteger e = readBigInteger("e", inputStream); + + BigInteger[] values = processRSASecretKey(inputStream, n, e, + keyProtectionRemoverFactory); + + // TODO: type of RSA key? + RSAPublicBCPGKey basePubKey = new RSAPublicBCPGKey(n, e); + RSAPublicBCPGKey assocPubKey = (RSAPublicBCPGKey) pubKey + .getPublicKeyPacket().getKey(); + if (!basePubKey.getModulus().equals(assocPubKey.getModulus()) + || !basePubKey.getPublicExponent() + .equals(assocPubKey.getPublicExponent())) { + throw new PGPException( + "passed in public key does not match secret key"); + } + + return new PGPSecretKey(new SecretKeyPacket( + pubKey.getPublicKeyPacket(), + SymmetricKeyAlgorithmTags.NULL, null, null, + new RSASecretBCPGKey(values[0], values[1], values[2]) + .getEncoded()), + pubKey); + } else { + throw new PGPException("unknown key type: " + keyType); + } + } + + throw new PGPException("unknown key type found"); + } + + /** + * Parse a secret key from one of the GPG S expression keys. + * + * @param inputStream + * to read from + * @param keyProtectionRemoverFactory + * for decrypting encrypted keys + * @param fingerPrintCalculator + * for calculating key fingerprints + * + * @return a secret key object. + * @throws IOException + * @throws PGPException + */ + public PGPSecretKey parseSecretKey(InputStream inputStream, + PBEProtectionRemoverFactory keyProtectionRemoverFactory, + KeyFingerPrintCalculator fingerPrintCalculator) + throws IOException, PGPException { + SXprUtils.skipOpenParenthesis(inputStream); + + String type; + + type = SXprUtils.readString(inputStream, inputStream.read()); + if (type.equals("protected-private-key") + || type.equals("private-key")) { + SXprUtils.skipOpenParenthesis(inputStream); + + String keyType = SXprUtils.readString(inputStream, + inputStream.read()); + if (keyType.equals("ecc")) { + SXprUtils.skipOpenParenthesis(inputStream); + + String curveID = SXprUtils.readString(inputStream, + inputStream.read()); + String curveName = SXprUtils.readString(inputStream, + inputStream.read()); + + if (curveName.startsWith("NIST ")) { + curveName = curveName.substring("NIST ".length()); + } + + SXprUtils.skipCloseParenthesis(inputStream); + + byte[] qVal; + + SXprUtils.skipOpenParenthesis(inputStream); + + type = SXprUtils.readString(inputStream, inputStream.read()); + if (type.equals("q")) { + qVal = SXprUtils.readBytes(inputStream, inputStream.read()); + } else { + throw new PGPException("no q value found"); + } + + PublicKeyPacket pubPacket = new PublicKeyPacket( + PublicKeyAlgorithmTags.ECDSA, new Date(), + new ECDSAPublicBCPGKey( + ECNamedCurveTable.getOID(curveName), + new BigInteger(1, qVal))); + + SXprUtils.skipCloseParenthesis(inputStream); + + BigInteger d = processECSecretKey(inputStream, curveID, + curveName, qVal, keyProtectionRemoverFactory); + + return new PGPSecretKey( + new SecretKeyPacket(pubPacket, + SymmetricKeyAlgorithmTags.NULL, null, null, + new ECSecretBCPGKey(d).getEncoded()), + new PGPPublicKey(pubPacket, fingerPrintCalculator)); + } else if (keyType.equals("dsa")) { + BigInteger p = readBigInteger("p", inputStream); + BigInteger q = readBigInteger("q", inputStream); + BigInteger g = readBigInteger("g", inputStream); + + BigInteger y = readBigInteger("y", inputStream); + + BigInteger x = processDSASecretKey(inputStream, p, q, g, y, + keyProtectionRemoverFactory); + + PublicKeyPacket pubPacket = new PublicKeyPacket( + PublicKeyAlgorithmTags.DSA, new Date(), + new DSAPublicBCPGKey(p, q, g, y)); + + return new PGPSecretKey( + new SecretKeyPacket(pubPacket, + SymmetricKeyAlgorithmTags.NULL, null, null, + new DSASecretBCPGKey(x).getEncoded()), + new PGPPublicKey(pubPacket, fingerPrintCalculator)); + } else if (keyType.equals("elg")) { + BigInteger p = readBigInteger("p", inputStream); + BigInteger g = readBigInteger("g", inputStream); + + BigInteger y = readBigInteger("y", inputStream); + + BigInteger x = processElGamalSecretKey(inputStream, p, g, y, + keyProtectionRemoverFactory); + + PublicKeyPacket pubPacket = new PublicKeyPacket( + PublicKeyAlgorithmTags.ELGAMAL_ENCRYPT, new Date(), + new ElGamalPublicBCPGKey(p, g, y)); + + return new PGPSecretKey( + new SecretKeyPacket(pubPacket, + SymmetricKeyAlgorithmTags.NULL, null, null, + new ElGamalSecretBCPGKey(x).getEncoded()), + new PGPPublicKey(pubPacket, fingerPrintCalculator)); + } else if (keyType.equals("rsa")) { + BigInteger n = readBigInteger("n", inputStream); + BigInteger e = readBigInteger("e", inputStream); + + BigInteger[] values = processRSASecretKey(inputStream, n, e, + keyProtectionRemoverFactory); + + // TODO: type of RSA key? + PublicKeyPacket pubPacket = new PublicKeyPacket( + PublicKeyAlgorithmTags.RSA_GENERAL, new Date(), + new RSAPublicBCPGKey(n, e)); + + return new PGPSecretKey( + new SecretKeyPacket(pubPacket, + SymmetricKeyAlgorithmTags.NULL, null, null, + new RSASecretBCPGKey(values[0], values[1], + values[2]).getEncoded()), + new PGPPublicKey(pubPacket, fingerPrintCalculator)); + } else { + throw new PGPException("unknown key type: " + keyType); + } + } + + throw new PGPException("unknown key type found"); + } + + private BigInteger readBigInteger(String expectedType, + InputStream inputStream) throws IOException, PGPException { + SXprUtils.skipOpenParenthesis(inputStream); + + String type = SXprUtils.readString(inputStream, inputStream.read()); + if (!type.equals(expectedType)) { + throw new PGPException(expectedType + " value expected"); + } + + byte[] nBytes = SXprUtils.readBytes(inputStream, inputStream.read()); + BigInteger v = new BigInteger(1, nBytes); + + SXprUtils.skipCloseParenthesis(inputStream); + + return v; + } + + private static byte[][] extractData(InputStream inputStream, + PBEProtectionRemoverFactory keyProtectionRemoverFactory) + throws PGPException, IOException { + byte[] data; + byte[] protectedAt = null; + + SXprUtils.skipOpenParenthesis(inputStream); + + String type = SXprUtils.readString(inputStream, inputStream.read()); + if (type.equals("protected")) { + String protection = SXprUtils.readString(inputStream, + inputStream.read()); + + SXprUtils.skipOpenParenthesis(inputStream); + + S2K s2k = SXprUtils.parseS2K(inputStream); + + byte[] iv = SXprUtils.readBytes(inputStream, inputStream.read()); + + SXprUtils.skipCloseParenthesis(inputStream); + + byte[] secKeyData = SXprUtils.readBytes(inputStream, + inputStream.read()); + + SXprUtils.skipCloseParenthesis(inputStream); + + PBESecretKeyDecryptor keyDecryptor = keyProtectionRemoverFactory + .createDecryptor(protection); + + // TODO: recognise other algorithms + byte[] key = keyDecryptor.makeKeyFromPassPhrase( + SymmetricKeyAlgorithmTags.AES_128, s2k); + + data = keyDecryptor.recoverKeyData( + SymmetricKeyAlgorithmTags.AES_128, key, iv, secKeyData, 0, + secKeyData.length); + + // check if protected at is present + if (inputStream.read() == '(') { + ByteArrayOutputStream bOut = new ByteArrayOutputStream(); + + bOut.write('('); + int ch; + while ((ch = inputStream.read()) >= 0 && ch != ')') { + bOut.write(ch); + } + + if (ch != ')') { + throw new IOException("unexpected end to SExpr"); + } + + bOut.write(')'); + + protectedAt = bOut.toByteArray(); + } + + SXprUtils.skipCloseParenthesis(inputStream); + SXprUtils.skipCloseParenthesis(inputStream); + } else if (type.equals("d") || type.equals("x")) { + // JGit modification: unencrypted DSA or ECC keys can have an "x" + // here + return null; + } else { + throw new PGPException("protected block not found"); + } + + return new byte[][] { data, protectedAt }; + } + + private BigInteger processDSASecretKey(InputStream inputStream, + BigInteger p, BigInteger q, BigInteger g, BigInteger y, + PBEProtectionRemoverFactory keyProtectionRemoverFactory) + throws IOException, PGPException { + String type; + byte[][] basicData = extractData(inputStream, + keyProtectionRemoverFactory); + + // JGit modification: handle unencrypted DSA keys + if (basicData == null) { + byte[] nBytes = SXprUtils.readBytes(inputStream, + inputStream.read()); + BigInteger x = new BigInteger(1, nBytes); + SXprUtils.skipCloseParenthesis(inputStream); + return x; + } + + byte[] keyData = basicData[0]; + byte[] protectedAt = basicData[1]; + + // + // parse the secret key S-expr + // + InputStream keyIn = new ByteArrayInputStream(keyData); + + SXprUtils.skipOpenParenthesis(keyIn); + SXprUtils.skipOpenParenthesis(keyIn); + + BigInteger x = readBigInteger("x", keyIn); + + SXprUtils.skipCloseParenthesis(keyIn); + + // JGit modification: OCB-encrypted keys don't have and don't need a + // hash + if (keyProtectionRemoverFactory instanceof OCBPBEProtectionRemoverFactory) { + return x; + } + + SXprUtils.skipOpenParenthesis(keyIn); + type = SXprUtils.readString(keyIn, keyIn.read()); + + if (!type.equals("hash")) { + throw new PGPException("hash keyword expected"); + } + type = SXprUtils.readString(keyIn, keyIn.read()); + + if (!type.equals("sha1")) { + throw new PGPException("hash keyword expected"); + } + + byte[] hashBytes = SXprUtils.readBytes(keyIn, keyIn.read()); + + SXprUtils.skipCloseParenthesis(keyIn); + + if (digestProvider != null) { + PGPDigestCalculator digestCalculator = digestProvider + .get(HashAlgorithmTags.SHA1); + + OutputStream dOut = digestCalculator.getOutputStream(); + + dOut.write(Strings.toByteArray("(3:dsa")); + writeCanonical(dOut, "p", p); + writeCanonical(dOut, "q", q); + writeCanonical(dOut, "g", g); + writeCanonical(dOut, "y", y); + writeCanonical(dOut, "x", x); + + // check protected-at + if (protectedAt != null) { + dOut.write(protectedAt); + } + + dOut.write(Strings.toByteArray(")")); + + byte[] check = digestCalculator.getDigest(); + if (!Arrays.constantTimeAreEqual(check, hashBytes)) { + throw new PGPException( + "checksum on protected data failed in SExpr"); + } + } + + return x; + } + + private BigInteger processElGamalSecretKey(InputStream inputStream, + BigInteger p, BigInteger g, BigInteger y, + PBEProtectionRemoverFactory keyProtectionRemoverFactory) + throws IOException, PGPException { + String type; + byte[][] basicData = extractData(inputStream, + keyProtectionRemoverFactory); + + // JGit modification: handle unencrypted EC keys + if (basicData == null) { + byte[] nBytes = SXprUtils.readBytes(inputStream, + inputStream.read()); + BigInteger x = new BigInteger(1, nBytes); + SXprUtils.skipCloseParenthesis(inputStream); + return x; + } + + byte[] keyData = basicData[0]; + byte[] protectedAt = basicData[1]; + + // + // parse the secret key S-expr + // + InputStream keyIn = new ByteArrayInputStream(keyData); + + SXprUtils.skipOpenParenthesis(keyIn); + SXprUtils.skipOpenParenthesis(keyIn); + + BigInteger x = readBigInteger("x", keyIn); + + SXprUtils.skipCloseParenthesis(keyIn); + + // JGit modification: OCB-encrypted keys don't have and don't need a + // hash + if (keyProtectionRemoverFactory instanceof OCBPBEProtectionRemoverFactory) { + return x; + } + + SXprUtils.skipOpenParenthesis(keyIn); + type = SXprUtils.readString(keyIn, keyIn.read()); + + if (!type.equals("hash")) { + throw new PGPException("hash keyword expected"); + } + type = SXprUtils.readString(keyIn, keyIn.read()); + + if (!type.equals("sha1")) { + throw new PGPException("hash keyword expected"); + } + + byte[] hashBytes = SXprUtils.readBytes(keyIn, keyIn.read()); + + SXprUtils.skipCloseParenthesis(keyIn); + + if (digestProvider != null) { + PGPDigestCalculator digestCalculator = digestProvider + .get(HashAlgorithmTags.SHA1); + + OutputStream dOut = digestCalculator.getOutputStream(); + + dOut.write(Strings.toByteArray("(3:elg")); + writeCanonical(dOut, "p", p); + writeCanonical(dOut, "g", g); + writeCanonical(dOut, "y", y); + writeCanonical(dOut, "x", x); + + // check protected-at + if (protectedAt != null) { + dOut.write(protectedAt); + } + + dOut.write(Strings.toByteArray(")")); + + byte[] check = digestCalculator.getDigest(); + if (!Arrays.constantTimeAreEqual(check, hashBytes)) { + throw new PGPException( + "checksum on protected data failed in SExpr"); + } + } + + return x; + } + + private BigInteger processECSecretKey(InputStream inputStream, + String curveID, String curveName, byte[] qVal, + PBEProtectionRemoverFactory keyProtectionRemoverFactory) + throws IOException, PGPException { + String type; + + byte[][] basicData = extractData(inputStream, + keyProtectionRemoverFactory); + + // JGit modification: handle unencrypted EC keys + if (basicData == null) { + byte[] nBytes = SXprUtils.readBytes(inputStream, + inputStream.read()); + BigInteger d = new BigInteger(1, nBytes); + SXprUtils.skipCloseParenthesis(inputStream); + return d; + } + + byte[] keyData = basicData[0]; + byte[] protectedAt = basicData[1]; + + // + // parse the secret key S-expr + // + InputStream keyIn = new ByteArrayInputStream(keyData); + + SXprUtils.skipOpenParenthesis(keyIn); + SXprUtils.skipOpenParenthesis(keyIn); + BigInteger d = readBigInteger("d", keyIn); + SXprUtils.skipCloseParenthesis(keyIn); + + // JGit modification: OCB-encrypted keys don't have and don't need a + // hash + if (keyProtectionRemoverFactory instanceof OCBPBEProtectionRemoverFactory) { + return d; + } + + SXprUtils.skipOpenParenthesis(keyIn); + + type = SXprUtils.readString(keyIn, keyIn.read()); + + if (!type.equals("hash")) { + throw new PGPException("hash keyword expected"); + } + type = SXprUtils.readString(keyIn, keyIn.read()); + + if (!type.equals("sha1")) { + throw new PGPException("hash keyword expected"); + } + + byte[] hashBytes = SXprUtils.readBytes(keyIn, keyIn.read()); + + SXprUtils.skipCloseParenthesis(keyIn); + + if (digestProvider != null) { + PGPDigestCalculator digestCalculator = digestProvider + .get(HashAlgorithmTags.SHA1); + + OutputStream dOut = digestCalculator.getOutputStream(); + + dOut.write(Strings.toByteArray("(3:ecc")); + + dOut.write(Strings.toByteArray("(" + curveID.length() + ":" + + curveID + curveName.length() + ":" + curveName + ")")); + + writeCanonical(dOut, "q", qVal); + writeCanonical(dOut, "d", d); + + // check protected-at + if (protectedAt != null) { + dOut.write(protectedAt); + } + + dOut.write(Strings.toByteArray(")")); + + byte[] check = digestCalculator.getDigest(); + + if (!Arrays.constantTimeAreEqual(check, hashBytes)) { + throw new PGPException( + "checksum on protected data failed in SExpr"); + } + } + + return d; + } + + private BigInteger[] processRSASecretKey(InputStream inputStream, + BigInteger n, BigInteger e, + PBEProtectionRemoverFactory keyProtectionRemoverFactory) + throws IOException, PGPException { + String type; + byte[][] basicData = extractData(inputStream, + keyProtectionRemoverFactory); + + byte[] keyData; + byte[] protectedAt = null; + + InputStream keyIn; + BigInteger d; + + if (basicData == null) { + keyIn = inputStream; + byte[] nBytes = SXprUtils.readBytes(inputStream, + inputStream.read()); + d = new BigInteger(1, nBytes); + + SXprUtils.skipCloseParenthesis(inputStream); + + } else { + keyData = basicData[0]; + protectedAt = basicData[1]; + + keyIn = new ByteArrayInputStream(keyData); + + SXprUtils.skipOpenParenthesis(keyIn); + SXprUtils.skipOpenParenthesis(keyIn); + d = readBigInteger("d", keyIn); + } + + // + // parse the secret key S-expr + // + + BigInteger p = readBigInteger("p", keyIn); + BigInteger q = readBigInteger("q", keyIn); + BigInteger u = readBigInteger("u", keyIn); + + // JGit modification: OCB-encrypted keys don't have and don't need a + // hash + if (basicData == null + || keyProtectionRemoverFactory instanceof OCBPBEProtectionRemoverFactory) { + return new BigInteger[] { d, p, q, u }; + } + + SXprUtils.skipCloseParenthesis(keyIn); + + SXprUtils.skipOpenParenthesis(keyIn); + type = SXprUtils.readString(keyIn, keyIn.read()); + + if (!type.equals("hash")) { + throw new PGPException("hash keyword expected"); + } + type = SXprUtils.readString(keyIn, keyIn.read()); + + if (!type.equals("sha1")) { + throw new PGPException("hash keyword expected"); + } + + byte[] hashBytes = SXprUtils.readBytes(keyIn, keyIn.read()); + + SXprUtils.skipCloseParenthesis(keyIn); + + if (digestProvider != null) { + PGPDigestCalculator digestCalculator = digestProvider + .get(HashAlgorithmTags.SHA1); + + OutputStream dOut = digestCalculator.getOutputStream(); + + dOut.write(Strings.toByteArray("(3:rsa")); + + writeCanonical(dOut, "n", n); + writeCanonical(dOut, "e", e); + writeCanonical(dOut, "d", d); + writeCanonical(dOut, "p", p); + writeCanonical(dOut, "q", q); + writeCanonical(dOut, "u", u); + + // check protected-at + if (protectedAt != null) { + dOut.write(protectedAt); + } + + dOut.write(Strings.toByteArray(")")); + + byte[] check = digestCalculator.getDigest(); + + if (!Arrays.constantTimeAreEqual(check, hashBytes)) { + throw new PGPException( + "checksum on protected data failed in SExpr"); + } + } + + return new BigInteger[] { d, p, q, u }; + } + + private void writeCanonical(OutputStream dOut, String label, BigInteger i) + throws IOException { + writeCanonical(dOut, label, i.toByteArray()); + } + + private void writeCanonical(OutputStream dOut, String label, byte[] data) + throws IOException { + dOut.write(Strings.toByteArray( + "(" + label.length() + ":" + label + data.length + ":")); + dOut.write(data); + dOut.write(Strings.toByteArray(")")); + } +} diff --git a/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/keys/SXprUtils.java b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/keys/SXprUtils.java new file mode 100644 index 0000000000..220aa285ff --- /dev/null +++ b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/keys/SXprUtils.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2000-2021 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) + * <p> + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without restriction, + *including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * </p> + * <p> + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * </p> + * <p> + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR + * PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * </p> + */ +package org.eclipse.jgit.gpg.bc.internal.keys; + +// This class is an unmodified copy from Bouncy Castle; needed because it's package-visible only and used by SExprParser. + +import java.io.IOException; +import java.io.InputStream; + +import org.bouncycastle.bcpg.HashAlgorithmTags; +import org.bouncycastle.bcpg.S2K; +import org.bouncycastle.util.io.Streams; + +/** + * Utility functions for looking a S-expression keys. This class will move when + * it finds a better home! + * <p> + * Format documented here: + * http://git.gnupg.org/cgi-bin/gitweb.cgi?p=gnupg.git;a=blob;f=agent/keyformat.txt;h=42c4b1f06faf1bbe71ffadc2fee0fad6bec91a97;hb=refs/heads/master + * </p> + */ +class SXprUtils { + private static int readLength(InputStream in, int ch) throws IOException { + int len = ch - '0'; + + while ((ch = in.read()) >= 0 && ch != ':') { + len = len * 10 + ch - '0'; + } + + return len; + } + + static String readString(InputStream in, int ch) throws IOException { + int len = readLength(in, ch); + + char[] chars = new char[len]; + + for (int i = 0; i != chars.length; i++) { + chars[i] = (char) in.read(); + } + + return new String(chars); + } + + static byte[] readBytes(InputStream in, int ch) throws IOException { + int len = readLength(in, ch); + + byte[] data = new byte[len]; + + Streams.readFully(in, data); + + return data; + } + + static S2K parseS2K(InputStream in) throws IOException { + skipOpenParenthesis(in); + + // Algorithm is hard-coded to SHA1 below anyway. + readString(in, in.read()); + byte[] iv = readBytes(in, in.read()); + final long iterationCount = Long.parseLong(readString(in, in.read())); + + skipCloseParenthesis(in); + + // we have to return the actual iteration count provided. + S2K s2k = new S2K(HashAlgorithmTags.SHA1, iv, (int) iterationCount) { + @Override + public long getIterationCount() { + return iterationCount; + } + }; + + return s2k; + } + + static void skipOpenParenthesis(InputStream in) throws IOException { + int ch = in.read(); + if (ch != '(') { + throw new IOException( + "unknown character encountered: " + (char) ch); //$NON-NLS-1$ + } + } + + static void skipCloseParenthesis(InputStream in) throws IOException { + int ch = in.read(); + if (ch != ')') { + throw new IOException("unknown character encountered"); //$NON-NLS-1$ + } + } +} diff --git a/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/keys/SecretKeys.java b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/keys/SecretKeys.java new file mode 100644 index 0000000000..269a1ba0f6 --- /dev/null +++ b/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/keys/SecretKeys.java @@ -0,0 +1,597 @@ +/* + * Copyright (C) 2021 Thomas Wolf <thomas.wolf@paranor.ch> 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.keys; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.StreamCorruptedException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.text.MessageFormat; +import java.util.Arrays; + +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPSecretKey; +import org.bouncycastle.openpgp.operator.PBEProtectionRemoverFactory; +import org.bouncycastle.openpgp.operator.PGPDigestCalculatorProvider; +import org.bouncycastle.openpgp.operator.jcajce.JcePBEProtectionRemoverFactory; +import org.bouncycastle.util.io.Streams; +import org.eclipse.jgit.api.errors.CanceledException; +import org.eclipse.jgit.errors.UnsupportedCredentialItem; +import org.eclipse.jgit.gpg.bc.internal.BCText; +import org.eclipse.jgit.util.RawParseUtils; + +/** + * Utilities for reading GPG secret keys from a gpg-agent key file. + */ +public final class SecretKeys { + + private SecretKeys() { + // No instantiation. + } + + /** + * Something that can supply a passphrase to decrypt an encrypted secret + * key. + */ + public interface PassphraseSupplier { + + /** + * Supplies a passphrase. + * + * @return the passphrase + * @throws PGPException + * if no passphrase can be obtained + * @throws CanceledException + * if the user canceled passphrase entry + * @throws UnsupportedCredentialItem + * if an internal error occurred + * @throws URISyntaxException + * if an internal error occurred + */ + char[] getPassphrase() throws PGPException, CanceledException, + UnsupportedCredentialItem, URISyntaxException; + } + + private static final byte[] PROTECTED_KEY = "protected-private-key" //$NON-NLS-1$ + .getBytes(StandardCharsets.US_ASCII); + + private static final byte[] OCB_PROTECTED = "openpgp-s2k3-ocb-aes" //$NON-NLS-1$ + .getBytes(StandardCharsets.US_ASCII); + + /** + * Reads a GPG secret key from the given stream. + * + * @param in + * {@link InputStream} to read from, doesn't need to be buffered + * @param calculatorProvider + * for checking digests + * @param passphraseSupplier + * for decrypting encrypted keys + * @param publicKey + * the secret key should be for + * @return the secret key + * @throws IOException + * if the stream cannot be parsed + * @throws PGPException + * if thrown by the underlying S-Expression parser, for instance + * when the passphrase is wrong + * @throws CanceledException + * if thrown by the {@code passphraseSupplier} + * @throws UnsupportedCredentialItem + * if thrown by the {@code passphraseSupplier} + * @throws URISyntaxException + * if thrown by the {@code passphraseSupplier} + */ + public static PGPSecretKey readSecretKey(InputStream in, + PGPDigestCalculatorProvider calculatorProvider, + PassphraseSupplier passphraseSupplier, PGPPublicKey publicKey) + throws IOException, PGPException, CanceledException, + UnsupportedCredentialItem, URISyntaxException { + byte[] data = Streams.readAll(in); + if (data.length == 0) { + throw new EOFException(); + } else if (data.length < 4 + PROTECTED_KEY.length) { + // +4 for "(21:" for a binary protected key + throw new IOException( + MessageFormat.format(BCText.get().secretKeyTooShort, + Integer.toUnsignedString(data.length))); + } + SExprParser parser = new SExprParser(calculatorProvider); + byte firstChar = data[0]; + try { + if (firstChar == '(') { + // Binary format. + PBEProtectionRemoverFactory decryptor = null; + if (matches(data, 4, PROTECTED_KEY)) { + // AES/CBC encrypted. + decryptor = new JcePBEProtectionRemoverFactory( + passphraseSupplier.getPassphrase(), + calculatorProvider); + } + try (InputStream sIn = new ByteArrayInputStream(data)) { + return parser.parseSecretKey(sIn, decryptor, publicKey); + } + } + // Assume it's the new key-value format. + try (ByteArrayInputStream keyIn = new ByteArrayInputStream(data)) { + byte[] rawData = keyFromNameValueFormat(keyIn); + if (!matches(rawData, 1, PROTECTED_KEY)) { + // Not encrypted human-readable format. + try (InputStream sIn = new ByteArrayInputStream( + convertSexpression(rawData))) { + return parser.parseSecretKey(sIn, null, publicKey); + } + } + // An encrypted key from a key-value file. Most likely AES/OCB + // encrypted. + boolean isOCB[] = { false }; + byte[] sExp = convertSexpression(rawData, isOCB); + PBEProtectionRemoverFactory decryptor; + if (isOCB[0]) { + decryptor = new OCBPBEProtectionRemoverFactory( + passphraseSupplier.getPassphrase(), + calculatorProvider, getAad(sExp)); + } else { + decryptor = new JcePBEProtectionRemoverFactory( + passphraseSupplier.getPassphrase(), + calculatorProvider); + } + try (InputStream sIn = new ByteArrayInputStream(sExp)) { + return parser.parseSecretKey(sIn, decryptor, publicKey); + } + } + } catch (IOException e) { + throw new PGPException(e.getLocalizedMessage(), e); + } + } + + /** + * Extract the AAD for the OCB decryption from an s-expression. + * + * @param sExp + * buffer containing a valid binary s-expression + * @return the AAD + */ + private static byte[] getAad(byte[] sExp) { + // Given a key + // @formatter:off + // (protected-private-key (rsa ... (protected openpgp-s2k3-ocb-aes ... )(protected-at ...))) + // A B C D + // The AAD is [A..B)[C..D). (From the binary serialized form.) + // @formatter:on + int i = 1; // Skip initial '(' + while (sExp[i] != '(') { + i++; + } + int aadStart = i++; + int aadEnd = skip(sExp, aadStart); + byte[] protectedPrefix = "(9:protected" //$NON-NLS-1$ + .getBytes(StandardCharsets.US_ASCII); + while (!matches(sExp, i, protectedPrefix)) { + i++; + } + int protectedStart = i; + int protectedEnd = skip(sExp, protectedStart); + byte[] aadData = new byte[aadEnd - aadStart + - (protectedEnd - protectedStart)]; + System.arraycopy(sExp, aadStart, aadData, 0, protectedStart - aadStart); + System.arraycopy(sExp, protectedEnd, aadData, protectedStart - aadStart, + aadEnd - protectedEnd); + return aadData; + } + + /** + * Skips a list including nested lists. + * + * @param sExp + * buffer containing valid binary s-expression data + * @param start + * index of the opening '(' of the list to skip + * @return the index after the closing ')' of the skipped list + */ + private static int skip(byte[] sExp, int start) { + int i = start + 1; + int depth = 1; + while (depth > 0) { + switch (sExp[i]) { + case '(': + depth++; + break; + case ')': + depth--; + break; + default: + // We must be on a length + int j = i; + while (sExp[j] >= '0' && sExp[j] <= '9') { + j++; + } + // j is on the colon + int length = Integer.parseInt( + new String(sExp, i, j - i, StandardCharsets.US_ASCII)); + i = j + length; + } + i++; + } + return i; + } + + /** + * Checks whether the {@code needle} matches {@code src} at offset + * {@code from}. + * + * @param src + * to match against {@code needle} + * @param from + * position in {@code src} to start matching + * @param needle + * to match against + * @return {@code true} if {@code src} contains {@code needle} at position + * {@code from}, {@code false} otherwise + */ + private static boolean matches(byte[] src, int from, byte[] needle) { + if (from < 0 || from + needle.length > src.length) { + return false; + } + return org.bouncycastle.util.Arrays.constantTimeAreEqual(needle.length, + src, from, needle, 0); + } + + /** + * Converts a human-readable serialized s-expression into a binary + * serialized s-expression. + * + * @param humanForm + * to convert + * @return the converted s-expression + * @throws IOException + * if the conversion fails + */ + private static byte[] convertSexpression(byte[] humanForm) + throws IOException { + boolean[] isOCB = { false }; + return convertSexpression(humanForm, isOCB); + } + + /** + * Converts a human-readable serialized s-expression into a binary + * serialized s-expression. + * + * @param humanForm + * to convert + * @param isOCB + * returns whether the s-expression specified AES/OCB encryption + * @return the converted s-expression + * @throws IOException + * if the conversion fails + */ + private static byte[] convertSexpression(byte[] humanForm, boolean[] isOCB) + throws IOException { + int pos = 0; + try (ByteArrayOutputStream out = new ByteArrayOutputStream( + humanForm.length)) { + while (pos < humanForm.length) { + byte b = humanForm[pos]; + if (b == '(' || b == ')') { + out.write(b); + pos++; + } else if (isGpgSpace(b)) { + pos++; + } else if (b == '#') { + // Hex value follows up to the next # + int i = ++pos; + while (i < humanForm.length && isHex(humanForm[i])) { + i++; + } + if (i == pos || humanForm[i] != '#') { + throw new StreamCorruptedException( + BCText.get().sexprHexNotClosed); + } + if ((i - pos) % 2 != 0) { + throw new StreamCorruptedException( + BCText.get().sexprHexOdd); + } + int l = (i - pos) / 2; + out.write(Integer.toString(l) + .getBytes(StandardCharsets.US_ASCII)); + out.write(':'); + while (pos < i) { + int x = (nibble(humanForm[pos]) << 4) + | nibble(humanForm[pos + 1]); + pos += 2; + out.write(x); + } + pos = i + 1; + } else if (isTokenChar(b)) { + // Scan the token + int start = pos++; + while (pos < humanForm.length + && isTokenChar(humanForm[pos])) { + pos++; + } + int l = pos - start; + if (pos - start == OCB_PROTECTED.length + && matches(humanForm, start, OCB_PROTECTED)) { + isOCB[0] = true; + } + out.write(Integer.toString(l) + .getBytes(StandardCharsets.US_ASCII)); + out.write(':'); + out.write(humanForm, start, pos - start); + } else if (b == '"') { + // Potentially quoted string. + int start = ++pos; + boolean escaped = false; + while (pos < humanForm.length + && (escaped || humanForm[pos] != '"')) { + int ch = humanForm[pos++]; + escaped = !escaped && ch == '\\'; + } + if (pos >= humanForm.length) { + throw new StreamCorruptedException( + BCText.get().sexprStringNotClosed); + } + // start is on the first character of the string, pos on the + // closing quote. + byte[] dq = dequote(humanForm, start, pos); + out.write(Integer.toString(dq.length) + .getBytes(StandardCharsets.US_ASCII)); + out.write(':'); + out.write(dq); + pos++; + } else { + throw new StreamCorruptedException( + MessageFormat.format(BCText.get().sexprUnhandled, + Integer.toHexString(b & 0xFF))); + } + } + return out.toByteArray(); + } + } + + /** + * GPG-style string de-quoting, which is basically C-style, with some + * literal CR/LF escaping. + * + * @param in + * buffer containing the quoted string + * @param from + * index after the opening quote in {@code in} + * @param to + * index of the closing quote in {@code in} + * @return the dequoted raw string value + * @throws StreamCorruptedException + */ + private static byte[] dequote(byte[] in, int from, int to) + throws StreamCorruptedException { + // Result must be shorter or have the same length + byte[] out = new byte[to - from]; + int j = 0; + int i = from; + while (i < to) { + byte b = in[i++]; + if (b != '\\') { + out[j++] = b; + continue; + } + if (i == to) { + throw new StreamCorruptedException( + BCText.get().sexprStringInvalidEscapeAtEnd); + } + b = in[i++]; + switch (b) { + case 'b': + out[j++] = '\b'; + break; + case 'f': + out[j++] = '\f'; + break; + case 'n': + out[j++] = '\n'; + break; + case 'r': + out[j++] = '\r'; + break; + case 't': + out[j++] = '\t'; + break; + case 'v': + out[j++] = 0x0B; + break; + case '"': + case '\'': + case '\\': + out[j++] = b; + break; + case '\r': + // Escaped literal line end. If an LF is following, skip that, + // too. + if (i < to && in[i] == '\n') { + i++; + } + break; + case '\n': + // Same for LF possibly followed by CR. + if (i < to && in[i] == '\r') { + i++; + } + break; + case 'x': + if (i + 1 >= to || !isHex(in[i]) || !isHex(in[i + 1])) { + throw new StreamCorruptedException( + BCText.get().sexprStringInvalidHexEscape); + } + out[j++] = (byte) ((nibble(in[i]) << 4) | nibble(in[i + 1])); + i += 2; + break; + case '0': + case '1': + case '2': + case '3': + if (i + 2 >= to || !isOctal(in[i]) || !isOctal(in[i + 1]) + || !isOctal(in[i + 2])) { + throw new StreamCorruptedException( + BCText.get().sexprStringInvalidOctalEscape); + } + out[j++] = (byte) (((((in[i] - '0') << 3) + | (in[i + 1] - '0')) << 3) | (in[i + 2] - '0')); + i += 3; + break; + default: + throw new StreamCorruptedException(MessageFormat.format( + BCText.get().sexprStringInvalidEscape, + Integer.toHexString(b & 0xFF))); + } + } + return Arrays.copyOf(out, j); + } + + /** + * Extracts the key from a GPG name-value-pair key file. + * <p> + * Package-visible for tests only. + * </p> + * + * @param in + * {@link InputStream} to read from; should be buffered + * @return the raw key data as extracted from the file + * @throws IOException + * if the {@code in} stream cannot be read or does not contain a + * key + */ + static byte[] keyFromNameValueFormat(InputStream in) throws IOException { + // It would be nice if we could use RawParseUtils here, but GPG compares + // names case-insensitively. We're only interested in the "Key:" + // name-value pair. + int[] nameLow = { 'k', 'e', 'y', ':' }; + int[] nameCap = { 'K', 'E', 'Y', ':' }; + int nameIdx = 0; + for (;;) { + int next = in.read(); + if (next < 0) { + throw new EOFException(); + } + if (next == '\n') { + nameIdx = 0; + } else if (nameIdx >= 0) { + if (nameLow[nameIdx] == next || nameCap[nameIdx] == next) { + nameIdx++; + if (nameIdx == nameLow.length) { + break; + } + } else { + nameIdx = -1; + } + } + } + // We're after "Key:". Read the value as continuation lines. + int last = ':'; + byte[] rawData; + try (ByteArrayOutputStream out = new ByteArrayOutputStream(8192)) { + for (;;) { + int next = in.read(); + if (next < 0) { + break; + } + if (last == '\n') { + if (next == ' ' || next == '\t') { + // Continuation line; skip this whitespace + last = next; + continue; + } + break; // Not a continuation line + } + out.write(next); + last = next; + } + rawData = out.toByteArray(); + } + // GPG trims off trailing whitespace, and a line having only whitespace + // is a single LF. + try (ByteArrayOutputStream out = new ByteArrayOutputStream( + rawData.length)) { + int lineStart = 0; + boolean trimLeading = true; + while (lineStart < rawData.length) { + int nextLineStart = RawParseUtils.nextLF(rawData, lineStart); + if (trimLeading) { + while (lineStart < nextLineStart + && isGpgSpace(rawData[lineStart])) { + lineStart++; + } + } + // Trim trailing + int i = nextLineStart - 1; + while (lineStart < i && isGpgSpace(rawData[i])) { + i--; + } + if (i <= lineStart) { + // Empty line signifies LF + out.write('\n'); + trimLeading = true; + } else { + out.write(rawData, lineStart, i - lineStart + 1); + trimLeading = false; + } + lineStart = nextLineStart; + } + return out.toByteArray(); + } + } + + private static boolean isGpgSpace(int ch) { + return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n'; + } + + private static boolean isTokenChar(int ch) { + switch (ch) { + case '-': + case '.': + case '/': + case '_': + case ':': + case '*': + case '+': + case '=': + return true; + default: + if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') + || (ch >= '0' && ch <= '9')) { + return true; + } + return false; + } + } + + private static boolean isHex(int ch) { + return (ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'F') + || (ch >= 'a' && ch <= 'f'); + } + + private static boolean isOctal(int ch) { + return (ch >= '0' && ch <= '7'); + } + + private static int nibble(int ch) { + if (ch >= '0' && ch <= '9') { + return ch - '0'; + } else if (ch >= 'A' && ch <= 'F') { + return ch - 'A' + 10; + } else if (ch >= 'a' && ch <= 'f') { + return ch - 'a' + 10; + } + return -1; + } +} |