Browse Source

GPG signature verification via BouncyCastle

Add a GpgSignatureVerifier interface, plus a factory to create
instances thereof that is provided via the ServiceLoader mechanism.

Implement the new interface for BouncyCastle. A verifier maintains
an internal LRU cache of previously found public keys to speed up
verifying multiple objects (tag or commits). Mergetags are not handled.

Provide a new VerifySignatureCommand in org.eclipse.jgit.api together
with a factory method Git.verifySignature(). The command can verify
signatures on tags or commits, and can be limited to accept only tags
or commits. Provide a new public WrongObjectTypeException thrown when
the command is limited to either tags or commits and a name resolves
to some other object kind.

In jgit.pgm, implement "git tag -v", "git log --show-signature", and
"git show --show-signature". The output is similar to command-line
gpg invoked via git, but not identical. In particular, lines are not
prefixed by "gpg:" but by "bc:".

Trust levels for public keys are read from the keys' trust packets,
not from GPG's internal trust database. A trust packet may or may
not be set. Command-line GPG produces more warning lines depending
on the trust level, warning about keys with a trust level below
"full".

There are no unit tests because JGit still doesn't have any setup to
do signing unit tests; this would require at least a faked .gpg
directory with pre-created key rings and keys, and a way to make the
BouncyCastle classes use that directory instead of the default. See
bug 547538 and also bug 544847.

Tested manually with a small test repository containing signed and
unsigned commits and tags, with signatures made with different keys
and made by command-line git using GPG 2.2.25 and by JGit using
BouncyCastle 1.65.

Bug: 547751
Change-Id: If7e34aeed6ca6636a92bf774d893d98f6d459181
Signed-off-by: Thomas Wolf <thomas.wolf@paranor.ch>
tags/v5.11.0.202102240950-m3
Thomas Wolf 3 years ago
parent
commit
3774fcc848
24 changed files with 1507 additions and 44 deletions
  1. 2
    0
      org.eclipse.jgit.gpg.bc/META-INF/MANIFEST.MF
  2. 1
    0
      org.eclipse.jgit.gpg.bc/resources/META-INF/services/org.eclipse.jgit.lib.GpgSignatureVerifierFactory
  3. 7
    0
      org.eclipse.jgit.gpg.bc/resources/org/eclipse/jgit/gpg/bc/internal/BCText.properties
  4. 16
    0
      org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BCText.java
  5. 57
    19
      org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgKeyLocator.java
  6. 388
    0
      org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSignatureVerifier.java
  7. 28
    0
      org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSignatureVerifierFactory.java
  8. 3
    3
      org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSigner.java
  9. 5
    2
      org.eclipse.jgit.pgm/resources/org/eclipse/jgit/pgm/internal/CLIText.properties
  10. 42
    2
      org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/Log.java
  11. 43
    4
      org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/Show.java
  12. 76
    9
      org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/Tag.java
  13. 2
    1
      org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/internal/CLIText.java
  14. 56
    0
      org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/internal/VerificationUtils.java
  15. 9
    0
      org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties
  16. 11
    1
      org.eclipse.jgit/src/org/eclipse/jgit/api/Git.java
  17. 46
    0
      org.eclipse.jgit/src/org/eclipse/jgit/api/VerificationResult.java
  18. 307
    0
      org.eclipse.jgit/src/org/eclipse/jgit/api/VerifySignatureCommand.java
  19. 65
    0
      org.eclipse.jgit/src/org/eclipse/jgit/api/errors/WrongObjectTypeException.java
  20. 10
    1
      org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java
  21. 158
    0
      org.eclipse.jgit/src/org/eclipse/jgit/lib/GpgSignatureVerifier.java
  22. 71
    0
      org.eclipse.jgit/src/org/eclipse/jgit/lib/GpgSignatureVerifierFactory.java
  23. 18
    2
      org.eclipse.jgit/src/org/eclipse/jgit/revwalk/RevTag.java
  24. 86
    0
      org.eclipse.jgit/src/org/eclipse/jgit/util/SignatureUtils.java

+ 2
- 0
org.eclipse.jgit.gpg.bc/META-INF/MANIFEST.MF View File

@@ -9,11 +9,13 @@ Bundle-Localization: plugin
Bundle-Version: 5.11.0.qualifier
Bundle-RequiredExecutionEnvironment: JavaSE-1.8
Import-Package: org.bouncycastle.bcpg;version="[1.65.0,2.0.0)",
org.bouncycastle.bcpg.sig;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.jce.provider;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.encoders;version="[1.65.0,2.0.0)",

+ 1
- 0
org.eclipse.jgit.gpg.bc/resources/META-INF/services/org.eclipse.jgit.lib.GpgSignatureVerifierFactory View File

@@ -0,0 +1 @@
org.eclipse.jgit.gpg.bc.internal.BouncyCastleGpgSignatureVerifierFactory

+ 7
- 0
org.eclipse.jgit.gpg.bc/resources/org/eclipse/jgit/gpg/bc/internal/BCText.properties View File

@@ -8,4 +8,11 @@ gpgNoSecretKeyForPublicKey=unable to find associated secret key for public key:
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
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.

+ 16
- 0
org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BCText.java View File

@@ -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;
@@ -28,6 +37,13 @@ public final class BCText extends TranslationBundle {
/***/ public String gpgNotASigningKey;
/***/ public String gpgKeyInfo;
/***/ public String gpgSigningCancelled;
/***/ public String nonSignatureError;
/***/ public String signatureInconsistent;
/***/ public String signatureKeyLookupError;
/***/ public String signatureNoKeyInfo;
/***/ public String signatureNoPublicKey;
/***/ public String signatureParseError;
/***/ public String signatureVerificationError;
/***/ public String unableToSignCommitNoSecretKey;

}

+ 57
- 19
org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgKeyLocator.java View File

@@ -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,12 +14,14 @@ 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;
@@ -247,16 +249,32 @@ public class BouncyCastleGpgKeyLocator {
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;
}
@@ -270,10 +288,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);
}
}
@@ -285,6 +304,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
@@ -293,19 +316,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;
}
@@ -349,7 +375,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);
@@ -372,7 +399,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
@@ -562,6 +590,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
@@ -569,14 +602,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();
@@ -586,30 +621,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();
@@ -629,7 +667,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();
@@ -641,7 +679,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) {

+ 388
- 0
org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSignatureVerifier.java View File

@@ -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;
}
}
}

+ 28
- 0
org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSignatureVerifierFactory.java View File

@@ -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();
}

}

+ 3
- 3
org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/BouncyCastleGpgSigner.java View File

@@ -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
@@ -39,9 +39,9 @@ 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.GpgObjectSigner;
import org.eclipse.jgit.lib.ObjectBuilder;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.GpgConfig.GpgFormat;
@@ -210,7 +210,7 @@ public class BouncyCastleGpgSigner extends GpgSigner
}
}

private String extractSignerId(String pgpUserId) {
static String extractSignerId(String pgpUserId) {
int from = pgpUserId.indexOf('<');
if (from >= 0) {
int to = pgpUserId.indexOf('>', from + 1);

+ 5
- 2
org.eclipse.jgit.pgm/resources/org/eclipse/jgit/pgm/internal/CLIText.properties View File

@@ -77,14 +77,15 @@ invalidHttpProxyOnlyHttpSupported=Invalid http_proxy: {0}: Only http supported.
invalidRecurseSubmodulesMode=Invalid recurse submodules mode: {0}
invalidUntrackedFilesMode=Invalid untracked files mode ''{0}''
jgitVersion=jgit version {0}
lineFormat={0}
listeningOn=Listening on {0}
lfsNoAccessKey=No accessKey in {0}
lfsNoSecretKey=No secretKey in {0}
lfsProtocolUrl=LFS protocol URL: {0}
lfsStoreDirectory=LFS objects stored in: {0}
lfsStoreUrl=LFS store URL: {0}
lfsUnknownStoreType="Unknown LFS store type: {0}"
lineFormat={0}
listeningOn=Listening on {0}
logNoSignatureVerifier="No signature verifier available"
mergeConflict=CONFLICT(content): Merge conflict in {0}
mergeCheckoutConflict=error: Your local changes to the following files would be overwritten by merge:
mergeFailed=Automatic merge failed; fix conflicts and then commit the result
@@ -411,6 +412,7 @@ usage_show=Display one commit
usage_showRefNamesMatchingCommits=Show ref names matching commits
usage_showPatch=display patch
usage_showNotes=Add this ref to the list of note branches from which notes are displayed
usage_showSignature=Verify signatures of signed commits in the log
usage_showTimeInMilliseconds=Show mtime in milliseconds
usage_squash=Squash commits as if a real merge happened, but do not make a commit or move the HEAD.
usage_srcPrefix=show the source prefix instead of "a/"
@@ -424,6 +426,7 @@ usage_tagLocalUser=create a signed annotated tag using the specified GPG key ID
usage_tagMessage=create an annotated tag with the given message, unsigned unless -s or -u are given, or config tag.gpgSign is true, or tar.forceSignAnnotated is true and -a is not given
usage_tagSign=create a signed annotated tag
usage_tagNoSign=suppress signing the tag
usage_tagVerify=Verify the GPG signature
usage_untrackedFilesMode=show untracked files
usage_updateRef=reference to update
usage_updateRemoteRefsFromAnotherRepository=Update remote refs from another repository

+ 42
- 2
org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/Log.java View File

@@ -1,7 +1,7 @@
/*
* Copyright (C) 2010, Google Inc.
* Copyright (C) 2006-2008, Robin Rosenberg <robin.rosenberg@dewire.com>
* Copyright (C) 2008, Shawn O. Pearce <spearce@spearce.org> and others
* Copyright (C) 2006, 2008, Robin Rosenberg <robin.rosenberg@dewire.com>
* Copyright (C) 2008, 2021, Shawn O. Pearce <spearce@spearce.org> 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
@@ -31,12 +31,17 @@ import org.eclipse.jgit.diff.RenameDetector;
import org.eclipse.jgit.errors.LargeObjectException;
import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.GpgConfig;
import org.eclipse.jgit.lib.GpgSignatureVerifier;
import org.eclipse.jgit.lib.GpgSignatureVerifier.SignatureVerification;
import org.eclipse.jgit.lib.GpgSignatureVerifierFactory;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.notes.NoteMap;
import org.eclipse.jgit.pgm.internal.CLIText;
import org.eclipse.jgit.pgm.internal.VerificationUtils;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.util.GitDateFormatter;
@@ -68,6 +73,9 @@ class Log extends RevWalkTextBuiltin {
additionalNoteRefs.add(notesRef);
}

@Option(name = "--show-signature", usage = "usage_showSignature")
private boolean showSignature;

@Option(name = "--date", usage = "usage_date")
void dateFormat(String date) {
if (date.toLowerCase(Locale.ROOT).equals(date))
@@ -147,6 +155,10 @@ class Log extends RevWalkTextBuiltin {
// END -- Options shared with Diff


private GpgSignatureVerifier verifier;

private GpgConfig config;

Log() {
dateFormatter = new GitDateFormatter(Format.DEFAULT);
}
@@ -161,6 +173,7 @@ class Log extends RevWalkTextBuiltin {
/** {@inheritDoc} */
@Override
protected void run() {
config = new GpgConfig(db.getConfig());
diffFmt.setRepository(db);
try {
diffFmt.setPathFilter(pathFilter);
@@ -197,6 +210,9 @@ class Log extends RevWalkTextBuiltin {
throw die(e.getMessage(), e);
} finally {
diffFmt.close();
if (verifier != null) {
verifier.clear();
}
}
}

@@ -229,6 +245,9 @@ class Log extends RevWalkTextBuiltin {
}
outw.println();

if (showSignature) {
showSignature(c);
}
final PersonIdent author = c.getAuthorIdent();
outw.println(MessageFormat.format(CLIText.get().authorInfo, author.getName(), author.getEmailAddress()));
outw.println(MessageFormat.format(CLIText.get().dateInfo,
@@ -252,6 +271,27 @@ class Log extends RevWalkTextBuiltin {
outw.flush();
}

private void showSignature(RevCommit c) throws IOException {
if (c.getRawGpgSignature() == null) {
return;
}
if (verifier == null) {
GpgSignatureVerifierFactory factory = GpgSignatureVerifierFactory
.getDefault();
if (factory == null) {
throw die(CLIText.get().logNoSignatureVerifier, null);
}
verifier = factory.getVerifier();
}
SignatureVerification verification = verifier.verifySignature(c,
config);
if (verification == null) {
return;
}
VerificationUtils.writeVerification(outw, verification,
verifier.getName(), c.getCommitterIdent());
}

/**
* @param c
* @return <code>true</code> if at least one note was printed,

+ 43
- 4
org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/Show.java View File

@@ -29,10 +29,15 @@ import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.errors.RevisionSyntaxException;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.FileMode;
import org.eclipse.jgit.lib.GpgConfig;
import org.eclipse.jgit.lib.GpgSignatureVerifier;
import org.eclipse.jgit.lib.GpgSignatureVerifierFactory;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.GpgSignatureVerifier.SignatureVerification;
import org.eclipse.jgit.pgm.internal.CLIText;
import org.eclipse.jgit.pgm.internal.VerificationUtils;
import org.eclipse.jgit.pgm.opt.PathTreeFilterHandler;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevObject;
@@ -59,6 +64,9 @@ class Show extends TextBuiltin {
@Option(name = "--", metaVar = "metaVar_path", handler = PathTreeFilterHandler.class)
protected TreeFilter pathFilter = TreeFilter.ALL;

@Option(name = "--show-signature", usage = "usage_showSignature")
private boolean showSignature;

// BEGIN -- Options shared with Diff
@Option(name = "-p", usage = "usage_showPatch")
boolean showPatch;
@@ -220,13 +228,16 @@ class Show extends TextBuiltin {
}

outw.println();
String[] lines = tag.getFullMessage().split("\n"); //$NON-NLS-1$
for (String s : lines) {
outw.println(s);
String fullMessage = tag.getFullMessage();
if (!fullMessage.isEmpty()) {
String[] lines = tag.getFullMessage().split("\n"); //$NON-NLS-1$
for (String s : lines) {
outw.println(s);
}
}
byte[] rawSignature = tag.getRawGpgSignature();
if (rawSignature != null) {
lines = RawParseUtils.decode(rawSignature).split("\n"); //$NON-NLS-1$
String[] lines = RawParseUtils.decode(rawSignature).split("\n"); //$NON-NLS-1$
for (String s : lines) {
outw.println(s);
}
@@ -258,6 +269,10 @@ class Show extends TextBuiltin {
c.getId().copyTo(outbuffer, outw);
outw.println();

if (showSignature) {
showSignature(c);
}

final PersonIdent author = c.getAuthorIdent();
outw.println(MessageFormat.format(CLIText.get().authorInfo,
author.getName(), author.getEmailAddress()));
@@ -296,4 +311,28 @@ class Show extends TextBuiltin {
}
outw.println();
}

private void showSignature(RevCommit c) throws IOException {
if (c.getRawGpgSignature() == null) {
return;
}
GpgSignatureVerifierFactory factory = GpgSignatureVerifierFactory
.getDefault();
if (factory == null) {
throw die(CLIText.get().logNoSignatureVerifier, null);
}
GpgSignatureVerifier verifier = factory.getVerifier();
GpgConfig config = new GpgConfig(db.getConfig());
try {
SignatureVerification verification = verifier.verifySignature(c,
config);
if (verification == null) {
return;
}
VerificationUtils.writeVerification(outw, verification,
verifier.getName(), c.getCommitterIdent());
} finally {
verifier.clear();
}
}
}

+ 76
- 9
org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/Tag.java View File

@@ -4,7 +4,7 @@
* Copyright (C) 2008, Charles O'Farrell <charleso@charleso.org>
* Copyright (C) 2008, Robin Rosenberg <robin.rosenberg.lists@dewire.com>
* Copyright (C) 2008, Robin Rosenberg <robin.rosenberg@dewire.com>
* Copyright (C) 2008, 2020 Shawn O. Pearce <spearce@spearce.org> and others
* Copyright (C) 2008, 2021 Shawn O. Pearce <spearce@spearce.org> 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
@@ -22,43 +22,60 @@ import java.util.List;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.ListTagCommand;
import org.eclipse.jgit.api.TagCommand;
import org.eclipse.jgit.api.VerificationResult;
import org.eclipse.jgit.api.VerifySignatureCommand;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.api.errors.RefAlreadyExistsException;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.GpgSignatureVerifier.SignatureVerification;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.pgm.internal.CLIText;
import org.eclipse.jgit.pgm.internal.VerificationUtils;
import org.eclipse.jgit.revwalk.RevTag;
import org.eclipse.jgit.revwalk.RevWalk;
import org.kohsuke.args4j.Argument;
import org.kohsuke.args4j.Option;

@Command(common = true, usage = "usage_CreateATag")
class Tag extends TextBuiltin {
@Option(name = "-f", usage = "usage_forceReplacingAnExistingTag")

@Option(name = "--force", aliases = { "-f" }, forbids = { "--delete",
"--verify" }, usage = "usage_forceReplacingAnExistingTag")
private boolean force;

@Option(name = "-d", usage = "usage_tagDelete")
@Option(name = "--delete", aliases = { "-d" }, forbids = {
"--verify" }, usage = "usage_tagDelete")
private boolean delete;

@Option(name = "--annotate", aliases = {
"-a" }, usage = "usage_tagAnnotated")
"-a" }, forbids = { "--delete",
"--verify" }, usage = "usage_tagAnnotated")
private boolean annotated;

@Option(name = "-m", metaVar = "metaVar_message", usage = "usage_tagMessage")
@Option(name = "-m", forbids = { "--delete",
"--verify" }, metaVar = "metaVar_message", usage = "usage_tagMessage")
private String message;

@Option(name = "--sign", aliases = { "-s" }, forbids = {
"--no-sign" }, usage = "usage_tagSign")
"--no-sign", "--delete", "--verify" }, usage = "usage_tagSign")
private boolean sign;

@Option(name = "--no-sign", usage = "usage_tagNoSign", forbids = {
"--sign" })
"--sign", "--delete", "--verify" })
private boolean noSign;

@Option(name = "--local-user", aliases = {
"-u" }, metaVar = "metaVar_tagLocalUser", usage = "usage_tagLocalUser")
"-u" }, forbids = { "--delete",
"--verify" }, metaVar = "metaVar_tagLocalUser", usage = "usage_tagLocalUser")
private String gpgKeyId;

@Option(name = "--verify", aliases = { "-v" }, forbids = { "--delete",
"--force", "--annotate", "-m", "--sign", "--no-sign",
"--local-user" }, usage = "usage_tagVerify")
private boolean verify;

@Argument(index = 0, metaVar = "metaVar_name")
private String tagName;

@@ -70,7 +87,25 @@ class Tag extends TextBuiltin {
protected void run() {
try (Git git = new Git(db)) {
if (tagName != null) {
if (delete) {
if (verify) {
VerifySignatureCommand verifySig = git.verifySignature()
.setMode(VerifySignatureCommand.VerifyMode.TAGS)
.addName(tagName);

VerificationResult verification = verifySig.call()
.get(tagName);
if (verification == null) {
showUnsigned(git, tagName);
} else {
Throwable error = verification.getException();
if (error != null) {
throw die(error.getMessage(), error);
}
writeVerification(verifySig.getVerifier().getName(),
(RevTag) verification.getObject(),
verification.getVerification());
}
} else if (delete) {
List<String> deletedTags = git.tagDelete().setTags(tagName)
.call();
if (deletedTags.isEmpty()) {
@@ -116,4 +151,36 @@ class Tag extends TextBuiltin {
throw die(e.getMessage(), e);
}
}

private void showUnsigned(Git git, String wantedTag) throws IOException {
ObjectId id = git.getRepository().resolve(wantedTag);
if (id != null && !ObjectId.zeroId().equals(id)) {
try (RevWalk walk = new RevWalk(git.getRepository())) {
showTag(walk.parseTag(id));
}
} else {
throw die(
MessageFormat.format(CLIText.get().tagNotFound, wantedTag));
}
}

private void showTag(RevTag tag) throws IOException {
outw.println("object " + tag.getObject().name()); //$NON-NLS-1$
outw.println("type " + Constants.typeString(tag.getObject().getType())); //$NON-NLS-1$
outw.println("tag " + tag.getTagName()); //$NON-NLS-1$
outw.println("tagger " + tag.getTaggerIdent().toExternalString()); //$NON-NLS-1$
outw.println();
outw.print(tag.getFullMessage());
}

private void writeVerification(String name, RevTag tag,
SignatureVerification verification) throws IOException {
showTag(tag);
if (verification == null) {
outw.println();
return;
}
VerificationUtils.writeVerification(outw, verification, name,
tag.getTaggerIdent());
}
}

+ 2
- 1
org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/internal/CLIText.java View File

@@ -1,6 +1,6 @@
/*
* Copyright (C) 2010, 2013 Sasa Zivkov <sasa.zivkov@sap.com>
* Copyright (C) 2013, Obeo and others
* Copyright (C) 2013, 2021 Obeo 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
@@ -163,6 +163,7 @@ public class CLIText extends TranslationBundle {
/***/ public String lfsUnknownStoreType;
/***/ public String lineFormat;
/***/ public String listeningOn;
/***/ public String logNoSignatureVerifier;
/***/ public String mergeCheckoutConflict;
/***/ public String mergeConflict;
/***/ public String mergeFailed;

+ 56
- 0
org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/internal/VerificationUtils.java View File

@@ -0,0 +1,56 @@
/*
* 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.pgm.internal;

import java.io.IOException;

import org.eclipse.jgit.lib.GpgSignatureVerifier.SignatureVerification;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.util.GitDateFormatter;
import org.eclipse.jgit.util.SignatureUtils;
import org.eclipse.jgit.util.io.ThrowingPrintWriter;

/**
* Utilities for signature verification.
*/
public final class VerificationUtils {

private VerificationUtils() {
// No instantiation
}

/**
* Writes information about a signature verification to the given writer.
*
* @param out
* to write to
* @param verification
* to show
* @param name
* of the verifier used
* @param creator
* of the object verified; used for time zone information
* @throws IOException
* if writing fails
*/
public static void writeVerification(ThrowingPrintWriter out,
SignatureVerification verification, String name,
PersonIdent creator) throws IOException {
String[] text = SignatureUtils
.toString(verification, creator,
new GitDateFormatter(GitDateFormatter.Format.LOCALE))
.split("\n"); //$NON-NLS-1$
for (String line : text) {
out.print(name);
out.print(": "); //$NON-NLS-1$
out.println(line);
}
}
}

+ 9
- 0
org.eclipse.jgit/resources/org/eclipse/jgit/internal/JGitText.properties View File

@@ -622,6 +622,8 @@ shortCompressedStreamAt=Short compressed stream at {0}
shortReadOfBlock=Short read of block.
shortReadOfOptionalDIRCExtensionExpectedAnotherBytes=Short read of optional DIRC extension {0}; expected another {1} bytes within the section.
shortSkipOfBlock=Short skip of block.
signatureVerificationError=Signature verification failed
signatureVerificationUnavailable=No signature verifier registered
signedTagMessageNoLf=A non-empty message of a signed tag must end in LF.
signingServiceUnavailable=Signing service is not available
similarityScoreMustBeWithinBounds=Similarity score must be between 0 and 100.
@@ -763,6 +765,13 @@ uriNotFoundWithMessage={0} not found: {1}
URINotSupported=URI not supported: {0}
userConfigInvalid=Git config in the user's home directory {0} is invalid {1}
validatingGitModules=Validating .gitmodules files
verifySignatureBad=BAD signature from "{0}"
verifySignatureExpired=Expired signature from "{0}"
verifySignatureGood=Good signature from "{0}"
verifySignatureIssuer=issuer "{0}"
verifySignatureKey=using key {0}
verifySignatureMade=Signature made {0}
verifySignatureTrust=[{0}]
walkFailure=Walk failure.
wantNoSpaceWithCapabilities=No space between oid and first capability in first want line
wantNotValid=want {0} not valid

+ 11
- 1
org.eclipse.jgit/src/org/eclipse/jgit/api/Git.java View File

@@ -1,6 +1,6 @@
/*
* Copyright (C) 2010, Christian Halstrick <christian.halstrick@sap.com>
* Copyright (C) 2010, Chris Aniszczyk <caniszczyk@gmail.com> and others
* Copyright (C) 2010, 2021 Chris Aniszczyk <caniszczyk@gmail.com> 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
@@ -772,6 +772,16 @@ public class Git implements AutoCloseable {
return new RemoteSetUrlCommand(repo);
}

/**
* Return a command to verify signatures of tags or commits.
*
* @return a {@link VerifySignatureCommand}
* @since 5.11
*/
public VerifySignatureCommand verifySignature() {
return new VerifySignatureCommand(repo);
}

/**
* Get repository
*

+ 46
- 0
org.eclipse.jgit/src/org/eclipse/jgit/api/VerificationResult.java View File

@@ -0,0 +1,46 @@
/*
* 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.api;

import org.eclipse.jgit.lib.GpgSignatureVerifier;
import org.eclipse.jgit.revwalk.RevObject;

/**
* A {@code VerificationResult} describes the outcome of a signature
* verification.
*
* @see VerifySignatureCommand
*
* @since 5.11
*/
public interface VerificationResult {

/**
* If an error occurred during signature verification, this retrieves the
* exception.
*
* @return the exception, or {@code null} if none occurred
*/
Throwable getException();

/**
* Retrieves the signature verification result.
*
* @return the result, or {@code null} if none was computed
*/
GpgSignatureVerifier.SignatureVerification getVerification();

/**
* Retrieves the git object of which the signature was verified.
*
* @return the git object
*/
RevObject getObject();
}

+ 307
- 0
org.eclipse.jgit/src/org/eclipse/jgit/api/VerifySignatureCommand.java View File

@@ -0,0 +1,307 @@
/*
* 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.api;

import java.io.IOException;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.eclipse.jgit.annotations.NonNull;
import org.eclipse.jgit.api.errors.JGitInternalException;
import org.eclipse.jgit.api.errors.ServiceUnavailableException;
import org.eclipse.jgit.api.errors.WrongObjectTypeException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.GpgConfig;
import org.eclipse.jgit.lib.GpgSignatureVerifier;
import org.eclipse.jgit.lib.GpgSignatureVerifier.SignatureVerification;
import org.eclipse.jgit.lib.GpgSignatureVerifierFactory;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevObject;
import org.eclipse.jgit.revwalk.RevWalk;

/**
* A command to verify GPG signatures on tags or commits.
*
* @since 5.11
*/
public class VerifySignatureCommand extends GitCommand<Map<String, VerificationResult>> {

/**
* Describes what kind of objects shall be handled by a
* {@link VerifySignatureCommand}.
*/
public enum VerifyMode {
/**
* Handle any object type, ignore anything that is not a commit or tag.
*/
ANY,
/**
* Handle only commits; throw a {@link WrongObjectTypeException} for
* anything else.
*/
COMMITS,
/**
* Handle only tags; throw a {@link WrongObjectTypeException} for
* anything else.
*/
TAGS
}

private final Set<String> namesToCheck = new HashSet<>();

private VerifyMode mode = VerifyMode.ANY;

private GpgSignatureVerifier verifier;

private GpgConfig config;

private boolean ownVerifier;

/**
* Creates a new {@link VerifySignatureCommand} for the given {@link Repository}.
*
* @param repo
* to operate on
*/
public VerifySignatureCommand(Repository repo) {
super(repo);
}

/**
* Add a name of an object (SHA-1, ref name; anything that can be
* {@link Repository#resolve(String) resolved}) to the command to have its
* signature verified.
*
* @param name
* to add
* @return {@code this}
*/
public VerifySignatureCommand addName(String name) {
checkCallable();
namesToCheck.add(name);
return this;
}

/**
* Add names of objects (SHA-1, ref name; anything that can be
* {@link Repository#resolve(String) resolved}) to the command to have their
* signatures verified.
*
* @param names
* to add; duplicates will be ignored
* @return {@code this}
*/
public VerifySignatureCommand addNames(String... names) {
checkCallable();
namesToCheck.addAll(Arrays.asList(names));
return this;
}

/**
* Add names of objects (SHA-1, ref name; anything that can be
* {@link Repository#resolve(String) resolved}) to the command to have their
* signatures verified.
*
* @param names
* to add; duplicates will be ignored
* @return {@code this}
*/
public VerifySignatureCommand addNames(Collection<String> names) {
checkCallable();
namesToCheck.addAll(names);
return this;
}

/**
* Sets the mode of operation for this command.
*
* @param mode
* the {@link VerifyMode} to set
* @return {@code this}
*/
public VerifySignatureCommand setMode(@NonNull VerifyMode mode) {
checkCallable();
this.mode = mode;
return this;
}

/**
* Sets the {@link GpgSignatureVerifier} to use.
*
* @param verifier
* the {@link GpgSignatureVerifier} to use, or {@code null} to
* use the default verifier
* @return {@code this}
*/
public VerifySignatureCommand setVerifier(GpgSignatureVerifier verifier) {
checkCallable();
this.verifier = verifier;
return this;
}

/**
* Sets an external {@link GpgConfig} to use. Whether it will be used it at
* the discretion of the {@link #setVerifier(GpgSignatureVerifier)}.
*
* @param config
* to set; if {@code null}, the config will be loaded from the
* git config of the repository
* @return {@code this}
* @since 5.11
*/
public VerifySignatureCommand setGpgConfig(GpgConfig config) {
checkCallable();
this.config = config;
return this;
}

/**
* Retrieves the currently set {@link GpgSignatureVerifier}. Can be used
* after a successful {@link #call()} to get the verifier that was used.
*
* @return the {@link GpgSignatureVerifier}
*/
public GpgSignatureVerifier getVerifier() {
return verifier;
}

/**
* {@link Repository#resolve(String) Resolves} all names added to the
* command to git objects and verifies their signature. Non-existing objects
* are ignored.
* <p>
* Depending on the {@link #setMode(VerifyMode)}, only tags or commits or
* any kind of objects are allowed.
* </p>
* <p>
* Unsigned objects are silently skipped.
* </p>
*
* @return a map of the given names to the corresponding
* {@link VerificationResult}, excluding ignored or skipped objects.
* @throws ServiceUnavailableException
* if no {@link GpgSignatureVerifier} was set and no
* {@link GpgSignatureVerifierFactory} is available
* @throws WrongObjectTypeException
* if a name resolves to an object of a type not allowed by the
* {@link #setMode(VerifyMode)} mode
*/
@Override
@NonNull
public Map<String, VerificationResult> call()
throws ServiceUnavailableException, WrongObjectTypeException {
checkCallable();
setCallable(false);
Map<String, VerificationResult> result = new HashMap<>();
if (verifier == null) {
GpgSignatureVerifierFactory factory = GpgSignatureVerifierFactory
.getDefault();
if (factory == null) {
throw new ServiceUnavailableException(
JGitText.get().signatureVerificationUnavailable);
}
verifier = factory.getVerifier();
ownVerifier = true;
}
if (config == null) {
config = new GpgConfig(repo.getConfig());
}
try (RevWalk walk = new RevWalk(repo)) {
for (String toCheck : namesToCheck) {
ObjectId id = repo.resolve(toCheck);
if (id != null && !ObjectId.zeroId().equals(id)) {
RevObject object;
try {
object = walk.parseAny(id);
} catch (MissingObjectException e) {
continue;
}
VerificationResult verification = verifyOne(object);
if (verification != null) {
result.put(toCheck, verification);
}
}
}
} catch (IOException e) {
throw new JGitInternalException(
JGitText.get().signatureVerificationError, e);
} finally {
if (ownVerifier) {
verifier.clear();
}
}
return result;
}

private VerificationResult verifyOne(RevObject object)
throws WrongObjectTypeException, IOException {
int type = object.getType();
if (VerifyMode.TAGS.equals(mode) && type != Constants.OBJ_TAG) {
throw new WrongObjectTypeException(object, Constants.OBJ_TAG);
} else if (VerifyMode.COMMITS.equals(mode)
&& type != Constants.OBJ_COMMIT) {
throw new WrongObjectTypeException(object, Constants.OBJ_COMMIT);
}
if (type == Constants.OBJ_COMMIT || type == Constants.OBJ_TAG) {
try {
GpgSignatureVerifier.SignatureVerification verification = verifier
.verifySignature(object, config);
if (verification == null) {
// Not signed
return null;
}
// Create new result
return new Result(object, verification, null);
} catch (JGitInternalException e) {
return new Result(object, null, e);
}
}
return null;
}

private static class Result implements VerificationResult {

private final Throwable throwable;

private final SignatureVerification verification;

private final RevObject object;

public Result(RevObject object, SignatureVerification verification,
Throwable throwable) {
this.object = object;
this.verification = verification;
this.throwable = throwable;
}

@Override
public Throwable getException() {
return throwable;
}

@Override
public SignatureVerification getVerification() {
return verification;
}

@Override
public RevObject getObject() {
return object;
}

}
}

+ 65
- 0
org.eclipse.jgit/src/org/eclipse/jgit/api/errors/WrongObjectTypeException.java View File

@@ -0,0 +1,65 @@
/*
* 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.api.errors;

import java.text.MessageFormat;

import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;

/**
* A given object is not of an expected object type.
*
* @since 5.11
*/
public class WrongObjectTypeException extends GitAPIException {

private static final long serialVersionUID = 1L;

private String name;

private int type;

/**
* Construct a {@link WrongObjectTypeException} for the specified object id,
* giving the expected type.
*
* @param id
* {@link ObjectId} of the object with the unexpected type
* @param type
* expected object type code; see
* {@link Constants}{@code .OBJ_*}.
*/
public WrongObjectTypeException(ObjectId id, int type) {
super(MessageFormat.format(JGitText.get().objectIsNotA, id.name(),
Constants.typeString(type)));
this.name = id.name();
this.type = type;
}

/**
* Retrieves the name (SHA-1) of the object.
*
* @return the name
*/
public String getObjectId() {
return name;
}

/**
* Retrieves the expected type code. See {@link Constants}{@code .OBJ_*}.
*
* @return the type code
*/
public int getExpectedType() {
return type;
}
}

+ 10
- 1
org.eclipse.jgit/src/org/eclipse/jgit/internal/JGitText.java View File

@@ -1,6 +1,6 @@
/*
* Copyright (C) 2010, 2013 Sasa Zivkov <sasa.zivkov@sap.com>
* Copyright (C) 2012, Research In Motion Limited and others
* Copyright (C) 2012, 2021 Research In Motion Limited 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
@@ -650,6 +650,8 @@ public class JGitText extends TranslationBundle {
/***/ public String shortReadOfBlock;
/***/ public String shortReadOfOptionalDIRCExtensionExpectedAnotherBytes;
/***/ public String shortSkipOfBlock;
/***/ public String signatureVerificationError;
/***/ public String signatureVerificationUnavailable;
/***/ public String signedTagMessageNoLf;
/***/ public String signingServiceUnavailable;
/***/ public String similarityScoreMustBeWithinBounds;
@@ -791,6 +793,13 @@ public class JGitText extends TranslationBundle {
/***/ public String URINotSupported;
/***/ public String userConfigInvalid;
/***/ public String validatingGitModules;
/***/ public String verifySignatureBad;
/***/ public String verifySignatureExpired;
/***/ public String verifySignatureGood;
/***/ public String verifySignatureIssuer;
/***/ public String verifySignatureKey;
/***/ public String verifySignatureMade;
/***/ public String verifySignatureTrust;
/***/ public String walkFailure;
/***/ public String wantNoSpaceWithCapabilities;
/***/ public String wantNotValid;

+ 158
- 0
org.eclipse.jgit/src/org/eclipse/jgit/lib/GpgSignatureVerifier.java View File

@@ -0,0 +1,158 @@
/*
* 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.lib;

import java.io.IOException;
import java.util.Date;

import org.eclipse.jgit.annotations.NonNull;
import org.eclipse.jgit.annotations.Nullable;
import org.eclipse.jgit.api.errors.JGitInternalException;
import org.eclipse.jgit.revwalk.RevObject;

/**
* A {@code GpgVerifier} can verify GPG signatures on git commits and tags.
*
* @since 5.11
*/
public interface GpgSignatureVerifier {

/**
* Verifies the signature on a signed commit or tag.
*
* @param object
* to verify
* @param config
* the {@link GpgConfig} to use
* @return a {@link SignatureVerification} describing the outcome of the
* verification, or {@code null} if the object was not signed
* @throws IOException
* if an error occurs getting a public key
* @throws org.eclipse.jgit.api.errors.JGitInternalException
* if signature verification fails
*/
@Nullable
SignatureVerification verifySignature(@NonNull RevObject object,
@NonNull GpgConfig config) throws IOException;


/**
* Verifies a given signature for given data.
*
* @param data
* the signature is for
* @param signatureData
* the ASCII-armored signature
* @return a {@link SignatureVerification} describing the outcome
* @throws IOException
* if the signature cannot be parsed
* @throws JGitInternalException
* if signature verification fails
*/
public SignatureVerification verify(byte[] data, byte[] signatureData)
throws IOException;

/**
* Retrieves the name of this verifier. This should be a short string
* identifying the engine that verified the signature, like "gpg" if GPG is
* used, or "bc" for a BouncyCastle implementation.
*
* @return the name
*/
@NonNull
String getName();

/**
* A {@link GpgSignatureVerifier} may cache public keys to speed up
* verifying signatures on multiple objects. This clears this cache, if any.
*/
void clear();

/**
* A {@code SignatureVerification} returns data about a (positively or
* negatively) verified signature.
*/
interface SignatureVerification {

// Data about the signature.

@NonNull
Date getCreationDate();

// Data from the signature used to find a public key.

/**
* Obtains the signer as stored in the signature, if known.
*
* @return the signer, or {@code null} if unknown
*/
String getSigner();

/**
* Obtains the short or long fingerprint of the public key as stored in
* the signature, if known.
*
* @return the fingerprint, or {@code null} if unknown
*/
String getKeyFingerprint();

// Some information about the found public key.

/**
* Obtains the OpenPGP user ID associated with the key.
*
* @return the user id, or {@code null} if unknown
*/
String getKeyUser();

/**
* Tells whether the public key used for this signature verification was
* expired when the signature was created.
*
* @return {@code true} if the key was expired already, {@code false}
* otherwise
*/
boolean isExpired();

/**
* Obtains the trust level of the public key used to verify the
* signature.
*
* @return the trust level
*/
@NonNull
TrustLevel getTrustLevel();

// The verification result.

/**
* Tells whether the signature verification was successful.
*
* @return {@code true} if the signature was verified successfully;
* {@code false} if not.
*/
boolean getVerified();

/**
* Obtains a human-readable message giving additional information about
* the outcome of the verification.
*
* @return the message, or {@code null} if none set.
*/
String getMessage();
}

/**
* The owner's trust in a public key.
*/
enum TrustLevel {
UNKNOWN, NEVER, MARGINAL, FULL, ULTIMATE
}
}

+ 71
- 0
org.eclipse.jgit/src/org/eclipse/jgit/lib/GpgSignatureVerifierFactory.java View File

@@ -0,0 +1,71 @@
/*
* 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.lib;

import java.util.Iterator;
import java.util.ServiceConfigurationError;
import java.util.ServiceLoader;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* A {@code GpgSignatureVerifierFactory} creates {@link GpgSignatureVerifier} instances.
*
* @since 5.11
*/
public abstract class GpgSignatureVerifierFactory {

private static final Logger LOG = LoggerFactory
.getLogger(GpgSignatureVerifierFactory.class);

private static volatile GpgSignatureVerifierFactory defaultFactory = loadDefault();

private static GpgSignatureVerifierFactory loadDefault() {
try {
ServiceLoader<GpgSignatureVerifierFactory> loader = ServiceLoader
.load(GpgSignatureVerifierFactory.class);
Iterator<GpgSignatureVerifierFactory> iter = loader.iterator();
if (iter.hasNext()) {
return iter.next();
}
} catch (ServiceConfigurationError e) {
LOG.error(e.getMessage(), e);
}
return null;
}

/**
* Retrieves the default factory.
*
* @return the default factory or {@code null} if none set
*/
public static GpgSignatureVerifierFactory getDefault() {
return defaultFactory;
}

/**
* Sets the default factory.
*
* @param factory
* the new default factory
*/
public static void setDefault(GpgSignatureVerifierFactory factory) {
defaultFactory = factory;
}

/**
* Creates a new {@link GpgSignatureVerifier}.
*
* @return the new {@link GpgSignatureVerifier}
*/
public abstract GpgSignatureVerifier getVerifier();

}

+ 18
- 2
org.eclipse.jgit/src/org/eclipse/jgit/revwalk/RevTag.java View File

@@ -1,7 +1,7 @@
/*
* Copyright (C) 2008-2009, Google Inc.
* Copyright (C) 2008, 2009, Google Inc.
* Copyright (C) 2008, Marek Zawirski <marek.zawirski@gmail.com>
* Copyright (C) 2008, Shawn O. Pearce <spearce@spearce.org> and others
* Copyright (C) 2008, 2021, Shawn O. Pearce <spearce@spearce.org> 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
@@ -343,6 +343,22 @@ public class RevTag extends RevObject {
return tagName;
}

/**
* Obtain the raw unparsed tag body (<b>NOTE - THIS IS NOT A COPY</b>).
* <p>
* This method is exposed only to provide very fast, efficient access to
* this tag's message buffer. Applications relying on this buffer should be
* very careful to ensure they do not modify its contents during their use
* of it.
*
* @return the raw unparsed tag body. This is <b>NOT A COPY</b>. Do not
* alter the returned array.
* @since 5.11
*/
public final byte[] getRawBuffer() {
return buffer;
}

/**
* Discard the message buffer to reduce memory usage.
* <p>

+ 86
- 0
org.eclipse.jgit/src/org/eclipse/jgit/util/SignatureUtils.java View File

@@ -0,0 +1,86 @@
/*
* 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.util;

import java.text.MessageFormat;
import java.util.Locale;

import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.lib.GpgSignatureVerifier.SignatureVerification;
import org.eclipse.jgit.lib.GpgSignatureVerifier.TrustLevel;
import org.eclipse.jgit.lib.PersonIdent;

/**
* Utilities for signature verification.
*
* @since 5.11
*/
public final class SignatureUtils {

private SignatureUtils() {
// No instantiation
}

/**
* Writes information about a signature verification to a string.
*
* @param verification
* to show
* @param creator
* of the object verified; used for time zone information
* @param formatter
* to use for dates
* @return a textual representation of the {@link SignatureVerification},
* using LF as line separator
*/
public static String toString(SignatureVerification verification,
PersonIdent creator, GitDateFormatter formatter) {
StringBuilder result = new StringBuilder();
// Use the creator's timezone for the signature date
PersonIdent dateId = new PersonIdent(creator,
verification.getCreationDate());
result.append(MessageFormat.format(JGitText.get().verifySignatureMade,
formatter.formatDate(dateId)));
result.append('\n');
result.append(MessageFormat.format(
JGitText.get().verifySignatureKey,
verification.getKeyFingerprint().toUpperCase(Locale.ROOT)));
result.append('\n');
if (!StringUtils.isEmptyOrNull(verification.getSigner())) {
result.append(
MessageFormat.format(JGitText.get().verifySignatureIssuer,
verification.getSigner()));
result.append('\n');
}
String msg;
if (verification.getVerified()) {
if (verification.isExpired()) {
msg = JGitText.get().verifySignatureExpired;
} else {
msg = JGitText.get().verifySignatureGood;
}
} else {
msg = JGitText.get().verifySignatureBad;
}
result.append(MessageFormat.format(msg, verification.getKeyUser()));
if (!TrustLevel.UNKNOWN.equals(verification.getTrustLevel())) {
result.append(' ' + MessageFormat
.format(JGitText.get().verifySignatureTrust, verification
.getTrustLevel().name().toLowerCase(Locale.ROOT)));
}
result.append('\n');
msg = verification.getMessage();
if (!StringUtils.isEmptyOrNull(msg)) {
result.append(msg);
result.append('\n');
}
return result.toString();
}
}

Loading…
Cancel
Save