diff --git a/scm-core/src/main/java/sonia/scm/repository/Changeset.java b/scm-core/src/main/java/sonia/scm/repository/Changeset.java index 5edd01531e..4fb6aa4d6b 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Changeset.java +++ b/scm-core/src/main/java/sonia/scm/repository/Changeset.java @@ -86,7 +86,7 @@ public class Changeset extends BasicPropertiesAware implements ModelObject { */ private Collection contributors; - private List signatures; + private List signatures = new ArrayList<>(); public Changeset() {} @@ -376,9 +376,6 @@ public class Changeset extends BasicPropertiesAware implements ModelObject { * @since 2.3.0 */ public void addSignature(Signature signature) { - if (signatures == null) { - signatures = new ArrayList<>(); - } signatures.add(signature); } } diff --git a/scm-core/src/main/java/sonia/scm/repository/Signature.java b/scm-core/src/main/java/sonia/scm/repository/Signature.java index 4cada0e629..3e67613447 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Signature.java +++ b/scm-core/src/main/java/sonia/scm/repository/Signature.java @@ -26,6 +26,7 @@ package sonia.scm.repository; import lombok.Value; +import java.io.Serializable; import java.util.Optional; /** @@ -33,7 +34,9 @@ import java.util.Optional; * @since 2.3.0 */ @Value -public class Signature { +public class Signature implements Serializable { + + private static final long serialVersionUID = 1L; private final String key; private final String type; diff --git a/scm-core/src/main/java/sonia/scm/security/GPGPublicKey.java b/scm-core/src/main/java/sonia/scm/security/GPG.java similarity index 58% rename from scm-core/src/main/java/sonia/scm/security/GPGPublicKey.java rename to scm-core/src/main/java/sonia/scm/security/GPG.java index 731b958c09..c2cd6b8177 100644 --- a/scm-core/src/main/java/sonia/scm/security/GPGPublicKey.java +++ b/scm-core/src/main/java/sonia/scm/security/GPG.java @@ -24,17 +24,40 @@ package sonia.scm.security; -import lombok.Value; +import java.util.Optional; /** - * Public gpg key. + * Allows signing and verification using gpg. + * * @since 2.3.0 */ -@Value -public class GPGPublicKey { +public interface GPG { - private final String id; - private final String owner; - private final String raw; + /** + * Returns the id of the key from the given signature. + * @param signature signature + * @return public key id + */ + String findPublicKeyId(byte[] signature); + /** + * Returns the public key with the given id or an empty optional. + * @param id id of public + * @return public key or empty optional + */ + Optional findPublicKey(String id); + + /** + * Returns all public keys assigned to the given username + * @param username username of the public key owner + * @return collection of public keys + */ + Iterable findPublicKeysByUsername(String username); + + /** + * Returns the default private key of the currently authenticated user. + * + * @return default private key + */ + PrivateKey getPrivateKey(); } diff --git a/scm-core/src/main/java/sonia/scm/security/GPGPublicKeyResolver.java b/scm-core/src/main/java/sonia/scm/security/PrivateKey.java similarity index 72% rename from scm-core/src/main/java/sonia/scm/security/GPGPublicKeyResolver.java rename to scm-core/src/main/java/sonia/scm/security/PrivateKey.java index 56158ec36a..dc62a4ca74 100644 --- a/scm-core/src/main/java/sonia/scm/security/GPGPublicKeyResolver.java +++ b/scm-core/src/main/java/sonia/scm/security/PrivateKey.java @@ -24,25 +24,28 @@ package sonia.scm.security; -import java.util.List; -import java.util.Optional; +import java.io.ByteArrayInputStream; +import java.io.InputStream; /** - * Resolver for public gpg keys. + * Can be used to create signatures of data. * @since 2.3.0 */ -public interface GPGPublicKeyResolver { +public interface PrivateKey { /** - * Resolves the public key by its id. - * @param keyId id of the key - * @return public gpg key or empty optional. + * Creates a signature for the given data. + * @param stream data stream to sign + * @return signature */ - Optional byId(String keyId); + byte[] sign(InputStream stream); /** - * Resolves all public gpg keys for the given user. - * @return list of public gpg keys + * Creates a signature for the given data. + * @param data data to sign + * @return signature */ - List byUser(String username); + default byte[] sign(byte[] data) { + return sign(new ByteArrayInputStream(data)); + } } diff --git a/scm-core/src/main/java/sonia/scm/security/PublicKey.java b/scm-core/src/main/java/sonia/scm/security/PublicKey.java new file mode 100644 index 0000000000..30e3fe1072 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/security/PublicKey.java @@ -0,0 +1,69 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * 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: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * 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. + */ + +package sonia.scm.security; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.Optional; + +/** + * The public key can be used to verify signatures. + * + * @since 2.3.0 + */ +public interface PublicKey { + + /** + * Returns id of the public key. + * + * @return id of key + */ + String getId(); + + /** + * Returns the username of the owner or an empty optional. + * + * @return owner or empty optional + */ + Optional getOwner(); + + /** + * Verifies that the signature is valid for the given data. + * @param stream stream of data to verify + * @param signature signature + * @return {@code true} if the signature is valid for the given data + */ + boolean verify(InputStream stream, byte[] signature); + + /** + * Verifies that the signature is valid for the given data. + * @param data data to verify + * @param signature signature + * @return {@code true} if the signature is valid for the given data + */ + default boolean verify(byte[] data, byte[] signature) { + return verify(new ByteArrayInputStream(data), signature); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java index 3b8e6dadad..779fab0dbf 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java @@ -33,16 +33,20 @@ import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.treewalk.TreeWalk; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.eclipse.jgit.util.RawParseUtils; +import sonia.scm.security.GPG; +import sonia.scm.security.PublicKey; import sonia.scm.util.Util; +import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Optional; //~--- JDK imports ------------------------------------------------------------ @@ -50,121 +54,35 @@ import java.util.List; * * @author Sebastian Sdorra */ -public class GitChangesetConverter implements Closeable -{ +public class GitChangesetConverter implements Closeable { - /** - * the logger for GitChangesetConverter - */ - private static final Logger logger = - LoggerFactory.getLogger(GitChangesetConverter.class); - - //~--- constructors --------------------------------------------------------- - - /** - * Constructs ... - * - * - * @param repository - */ - GitChangesetConverter(org.eclipse.jgit.lib.Repository repository) - { - this(repository, null); - } - - /** - * Constructs ... - * - * - * @param repository - * @param revWalk - */ - GitChangesetConverter(org.eclipse.jgit.lib.Repository repository, - RevWalk revWalk) - { - this.repository = repository; - - if (revWalk != null) - { - this.revWalk = revWalk; - - } - else - { - this.revWalk = new RevWalk(repository); - } + private final GPG gpg; + private final Multimap tags; + private final TreeWalk treeWalk; + GitChangesetConverter(GPG gpg, org.eclipse.jgit.lib.Repository repository, RevWalk revWalk) { + this.gpg = gpg; this.tags = GitUtil.createTagMap(repository, revWalk); - treeWalk = new TreeWalk(repository); + this.treeWalk = new TreeWalk(repository); } - //~--- methods -------------------------------------------------------------- - - /** - * Method description - * - */ - @Override - public void close() - { - GitUtil.release(treeWalk); - } - - /** - * Method description - * - * - * @param commit - * - * @return - * - * @throws IOException - */ - public Changeset createChangeset(RevCommit commit) - { + public Changeset createChangeset(RevCommit commit) { return createChangeset(commit, Collections.emptyList()); } - /** - * Method description - * - * - * @param commit - * @param branch - * - * @return - * - * @throws IOException - */ - public Changeset createChangeset(RevCommit commit, String branch) - { + public Changeset createChangeset(RevCommit commit, String branch) { return createChangeset(commit, Lists.newArrayList(branch)); } - /** - * Method description - * - * - * - * @param commit - * @param branches - * - * @return - * - * @throws IOException - */ - public Changeset createChangeset(RevCommit commit, List branches) - { + public Changeset createChangeset(RevCommit commit, List branches) { String id = commit.getId().name(); List parentList = null; RevCommit[] parents = commit.getParents(); - if (Util.isNotEmpty(parents)) - { - parentList = new ArrayList(); + if (Util.isNotEmpty(parents)) { + parentList = new ArrayList<>(); - for (RevCommit parent : parents) - { + for (RevCommit parent : parents) { parentList.add(parent.getId().name()); } } @@ -175,8 +93,7 @@ public class GitChangesetConverter implements Closeable Person author = createPersonFor(authorIndent); String message = commit.getFullMessage(); - if (message != null) - { + if (message != null) { message = message.trim(); } @@ -185,41 +102,73 @@ public class GitChangesetConverter implements Closeable changeset.addContributor(new Contributor("Committed-by", createPersonFor(committerIdent))); } - if (parentList != null) - { + if (parentList != null) { changeset.setParents(parentList); } Collection tagCollection = tags.get(commit.getId()); - if (Util.isNotEmpty(tagCollection)) - { - + if (Util.isNotEmpty(tagCollection)) { // create a copy of the tag collection to reduce memory on caching changeset.getTags().addAll(Lists.newArrayList(tagCollection)); } changeset.setBranches(branches); + Signature signature = createSignature(commit); + if (signature != null) { + changeset.addSignature(signature); + } + return changeset; } + private static final byte[] GPG_HEADER = {'g', 'p', 'g', 's', 'i', 'g'}; + + private Signature createSignature(RevCommit commit) { + byte[] raw = commit.getRawBuffer(); + + int start = RawParseUtils.headerStart(GPG_HEADER, raw, 0); + if (start < 0) { + return null; + } + + int end = RawParseUtils.headerEnd(raw, start); + byte[] signature = Arrays.copyOfRange(raw, start, end); + + String publicKeyId = gpg.findPublicKeyId(signature); + + Optional publicKeyById = gpg.findPublicKey(publicKeyId); + if (!publicKeyById.isPresent()) { + // key not found + return new Signature(publicKeyId, "gpg", false, null); + } + + PublicKey publicKey = publicKeyById.get(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + byte[] headerPrefix = Arrays.copyOfRange(raw, 0, start - GPG_HEADER.length - 1); + baos.write(headerPrefix); + + byte[] headerSuffix = Arrays.copyOfRange(raw, end + 1, raw.length); + baos.write(headerSuffix); + } catch (IOException ex) { + // this will never happen, because we are writing into memory + throw new IllegalStateException("failed to write into memory", ex); + } + + boolean verified = publicKey.verify(baos.toByteArray(), signature); + return new Signature(publicKeyId, "gpg", verified, publicKey.getOwner().orElse(null)); + } + public Person createPersonFor(PersonIdent personIndent) { return new Person(personIndent.getName(), personIndent.getEmailAddress()); } + @Override + public void close() { + GitUtil.release(treeWalk); + } - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private org.eclipse.jgit.lib.Repository repository; - - /** Field description */ - private RevWalk revWalk; - - /** Field description */ - private Multimap tags; - - /** Field description */ - private TreeWalk treeWalk; } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverterFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverterFactory.java index c5eb6e312a..4f4389fa2e 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverterFactory.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverterFactory.java @@ -26,15 +26,25 @@ package sonia.scm.repository; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevWalk; +import sonia.scm.security.GPG; + +import javax.inject.Inject; public class GitChangesetConverterFactory { + private final GPG gpg; + + @Inject + public GitChangesetConverterFactory(GPG gpg) { + this.gpg = gpg; + } + public GitChangesetConverter create(Repository repository) { - return new GitChangesetConverter(repository); + return new GitChangesetConverter(gpg, repository, new RevWalk(repository)); } public GitChangesetConverter create(Repository repository, RevWalk revWalk) { - return new GitChangesetConverter(repository, revWalk); + return new GitChangesetConverter(gpg, repository, revWalk); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/protocolcommand/git/BaseReceivePackFactoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/protocolcommand/git/BaseReceivePackFactoryTest.java index d6de6a0780..cc178c2ef8 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/protocolcommand/git/BaseReceivePackFactoryTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/protocolcommand/git/BaseReceivePackFactoryTest.java @@ -38,9 +38,9 @@ import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; -import sonia.scm.repository.GitChangesetConverterFactory; import sonia.scm.repository.GitConfig; import sonia.scm.repository.GitRepositoryHandler; +import sonia.scm.repository.GitTestHelper; import sonia.scm.web.CollectingPackParserListener; import sonia.scm.web.GitReceiveHook; @@ -83,7 +83,7 @@ public class BaseReceivePackFactoryTest { ReceivePack receivePack = new ReceivePack(repository); when(wrappedReceivePackFactory.create(request, repository)).thenReturn(receivePack); - factory = new BaseReceivePackFactory(new GitChangesetConverterFactory(), handler, null) { + factory = new BaseReceivePackFactory(GitTestHelper.createConverterFactory(), handler, null) { @Override protected ReceivePack createBasicReceivePack(Object request, Repository repository) throws ServiceNotEnabledException, ServiceNotAuthorizedException { return wrappedReceivePackFactory.create(request, repository); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitChangesetConverterTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitChangesetConverterTest.java new file mode 100644 index 0000000000..3a2a624fb5 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitChangesetConverterTest.java @@ -0,0 +1,303 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * 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: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * 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. + */ + +package sonia.scm.repository; + +import org.bouncycastle.bcpg.ArmoredOutputStream; +import org.bouncycastle.bcpg.BCPGOutputStream; +import org.bouncycastle.bcpg.HashAlgorithmTags; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPKeyPair; +import org.bouncycastle.openpgp.PGPPrivateKey; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.openpgp.PGPSignatureGenerator; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPKeyPair; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.api.errors.JGitInternalException; +import org.eclipse.jgit.internal.JGitText; +import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.GpgSignature; +import org.eclipse.jgit.lib.GpgSigner; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.security.GPG; +import sonia.scm.security.PublicKey; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.Security; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GitChangesetConverterTest { + + private static Git git; + + @BeforeAll + static void setUpRepository(@TempDir Path repositoryPath) throws GitAPIException { + // we use the same repository for all tests to speed things up + git = Git.init().setDirectory(repositoryPath.toFile()).call(); + } + + @AfterAll + static void closeRepository() { + git.close(); + } + + @Test + void shouldConvertChangeset() throws GitAPIException, IOException { + long now = System.currentTimeMillis() - 1000L; + Changeset changeset = commit( + "Tricia McMillan", "trillian@hitchhiker.com", "Added awesome markdown file" + ); + assertThat(changeset.getId()).isNotEmpty(); + assertThat(changeset.getDate()).isGreaterThanOrEqualTo(now); + assertThat(changeset.getDescription()).isEqualTo("Added awesome markdown file"); + + Person author = changeset.getAuthor(); + assertThat(author.getName()).isEqualTo("Tricia McMillan"); + assertThat(author.getMail()).isEqualTo("trillian@hitchhiker.com"); + } + + private Changeset commit(String name, String mail, String message) throws GitAPIException, IOException { + addRandomFileToRepository(); + + RevCommit commit = git.commit() + .setAuthor(name, mail) + .setMessage(message) + .call(); + + GitChangesetConverterFactory converterFactory = GitTestHelper.createConverterFactory(); + return converterFactory.create(git.getRepository()).createChangeset(commit); + } + + private void addRandomFileToRepository() throws IOException, GitAPIException { + File directory = git.getRepository().getWorkTree(); + String name = UUID.randomUUID().toString(); + File file = new File(directory, name + ".md"); + Files.write(file.toPath(), ("# Greetings\n\nFrom " + name).getBytes(StandardCharsets.UTF_8)); + git.add().addFilepattern(name + ".md").call(); + } + + @Nested + class SignatureTests { + + @Mock + private GPG gpg; + @Mock + private PublicKey publicKey; + + private PGPKeyPair keyPair; + private GpgSigner defaultSigner; + + @BeforeEach + void setUpTestingSignerAndCaptureDefault() throws Exception { + defaultSigner = GpgSigner.getDefault(); + // we use the same keypair for all tests to speed things up a little bit + if (keyPair == null) { + keyPair = createKeyPair(); + GpgSigner.setDefault(new TestingGpgSigner(keyPair)); + } + } + + @AfterEach + void restoreDefaultSigner() { + GpgSigner.setDefault(defaultSigner); + } + + @Test + void shouldReturnUnknownSignature() throws Exception { + String identity = "0xAWESOMExBOB"; + when(gpg.findPublicKeyId(any())).thenReturn(identity); + + Signature signature = addSignedCommitAndReturnSignature(identity); + assertThat(signature).isEqualTo(new Signature(identity, "gpg", false, null)); + } + + @Test + void shouldReturnKnownButInvalidSignature() throws Exception { + String identity = "0xAWESOMExBOB"; + String owner = "BobTheSigner"; + setPublicKey(identity, owner, false); + + Signature signature = addSignedCommitAndReturnSignature(identity); + assertThat(signature).isEqualTo(new Signature(identity, "gpg", false, owner)); + } + + @Test + void shouldReturnValidSignature() throws Exception { + String identity = "0xAWESOMExBOB"; + String owner = "BobTheSigner"; + setPublicKey(identity, owner, true); + + Signature signature = addSignedCommitAndReturnSignature(identity); + assertThat(signature).isEqualTo(new Signature(identity, "gpg", true, owner)); + } + + @Test + void shouldPassDataAndSignatureForVerification() throws Exception { + setPublicKey("0x42", "Me", true); + addSignedCommitAndReturnSignature("0x42"); + + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(byte[].class); + ArgumentCaptor signatureCaptor = ArgumentCaptor.forClass(byte[].class); + verify(publicKey).verify(dataCaptor.capture(), signatureCaptor.capture()); + + String data = new String(dataCaptor.getValue()); + assertThat(data).contains("author Bob The Signer "); + + String signature = new String(signatureCaptor.getValue()); + assertThat(signature).contains("BEGIN PGP SIGNATURE", "END PGP SIGNATURE"); + } + + @Test + void shouldNotReturnSignatureForNonSignedCommit() throws GitAPIException, IOException { + Changeset changeset = commit("Bob", "unsigned@bob.de", "not signed"); + assertThat(changeset.getSignatures()).isEmpty(); + } + + private void setPublicKey(String id, String owner, boolean valid) { + when(gpg.findPublicKeyId(any())).thenReturn(id); + when(gpg.findPublicKey(id)).thenReturn(Optional.of(publicKey)); + + when(publicKey.getOwner()).thenReturn(Optional.of(owner)); + when(publicKey.verify(any(byte[].class), any(byte[].class))).thenReturn(valid); + } + + private Signature addSignedCommitAndReturnSignature(String keyIdentity) throws IOException, GitAPIException { + RevCommit commit = addSignedCommit(keyIdentity); + GitChangesetConverterFactory factory = new GitChangesetConverterFactory(gpg); + GitChangesetConverter converter = factory.create(git.getRepository()); + + List signatures = converter.createChangeset(commit).getSignatures(); + assertThat(signatures).isNotEmpty().hasSize(1); + + return signatures.get(0); + } + + private RevCommit addSignedCommit(String keyIdentity) throws IOException, GitAPIException { + addRandomFileToRepository(); + return git.commit() + .setAuthor("Bob The Signer", "sign@bob.de") + .setMessage("Signed from Bob") + .setSign(true) + .setSigningKey(keyIdentity) + .call(); + } + + + } + private PGPKeyPair createKeyPair() throws PGPException, NoSuchProviderException, NoSuchAlgorithmException { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC"); + // we use a small key size to speedup test, a much larger size should be used for production + keyPairGenerator.initialize(512); + KeyPair pair = keyPairGenerator.generateKeyPair(); + return new JcaPGPKeyPair(PGPPublicKey.RSA_GENERAL, pair, new Date()); + } + + private static class TestingGpgSigner extends GpgSigner { + + private final PGPKeyPair keyPair; + + TestingGpgSigner(PGPKeyPair keyPair) { + this.keyPair = keyPair; + } + + @Override + public boolean canLocateSigningKey(String gpgSigningKey, PersonIdent committer, CredentialsProvider credentialsProvider) { + return true; + } + + @Override + public void sign(CommitBuilder commit, String gpgSigningKey, + PersonIdent committer, CredentialsProvider credentialsProvider) { + try { + if (keyPair == null) { + throw new JGitInternalException(JGitText.get().unableToSignCommitNoSecretKey); + } + + PGPPrivateKey privateKey = keyPair.getPrivateKey(); + + PGPSignatureGenerator signatureGenerator = new PGPSignatureGenerator( + new JcaPGPContentSignerBuilder( + keyPair.getPublicKey().getAlgorithm(), + HashAlgorithmTags.SHA256).setProvider(BouncyCastleProvider.PROVIDER_NAME) + ); + signatureGenerator.init(PGPSignature.BINARY_DOCUMENT, privateKey); + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try (BCPGOutputStream out = new BCPGOutputStream(new ArmoredOutputStream(buffer))) { + signatureGenerator.update(commit.build()); + signatureGenerator.generate().encode(out); + } + commit.setGpgSignature(new GpgSignature(buffer.toByteArray())); + } catch (PGPException | IOException e) { + throw new JGitInternalException(e.getMessage(), e); + } + } + + } + + // register bouncy castle provider on load + static { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } +} + + diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitTestHelper.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitTestHelper.java new file mode 100644 index 0000000000..cdf70db55d --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitTestHelper.java @@ -0,0 +1,65 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * 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: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * 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. + */ + +package sonia.scm.repository; + +import sonia.scm.security.GPG; +import sonia.scm.security.PrivateKey; +import sonia.scm.security.PublicKey; + +import java.util.Collections; +import java.util.Optional; + +public final class GitTestHelper { + + private GitTestHelper() { + } + + public static GitChangesetConverterFactory createConverterFactory() { + return new GitChangesetConverterFactory(new NoopGPG()); + } + + private static class NoopGPG implements GPG { + + @Override + public String findPublicKeyId(byte[] signature) { + return "secret-key"; + } + + @Override + public Optional findPublicKey(String id) { + return Optional.empty(); + } + + @Override + public Iterable findPublicKeysByUsername(String username) { + return Collections.emptySet(); + } + + @Override + public PrivateKey getPrivateKey() { + return null; + } + } +} diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/client/spi/GitCommitCommand.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/client/spi/GitCommitCommand.java index 9ea196c951..d17267b056 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/client/spi/GitCommitCommand.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/client/spi/GitCommitCommand.java @@ -33,6 +33,7 @@ import org.eclipse.jgit.revwalk.RevCommit; import sonia.scm.repository.Changeset; import sonia.scm.repository.GitChangesetConverter; import sonia.scm.repository.GitChangesetConverterFactory; +import sonia.scm.repository.GitTestHelper; import sonia.scm.repository.client.api.RepositoryClientException; //~--- JDK imports ------------------------------------------------------------ @@ -72,7 +73,7 @@ public class GitCommitCommand implements CommitCommand @Override public Changeset commit(CommitRequest request) throws IOException { - GitChangesetConverterFactory converterFactory = new GitChangesetConverterFactory(); + GitChangesetConverterFactory converterFactory = GitTestHelper.createConverterFactory(); try (GitChangesetConverter converter = converterFactory.create(git.getRepository())) { RevCommit commit = git.commit() diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractRemoteCommandTestBase.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractRemoteCommandTestBase.java index 36d0002de2..208d9a16bf 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractRemoteCommandTestBase.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/AbstractRemoteCommandTestBase.java @@ -42,6 +42,7 @@ import org.junit.rules.TemporaryFolder; import sonia.scm.repository.Changeset; import sonia.scm.repository.GitChangesetConverterFactory; import sonia.scm.repository.GitRepositoryHandler; +import sonia.scm.repository.GitTestHelper; import sonia.scm.repository.Repository; import sonia.scm.user.User; import sonia.scm.user.UserTestData; @@ -111,7 +112,7 @@ public class AbstractRemoteCommandTestBase { // store reference to handle weak references - proto = new ScmTransportProtocol(GitChangesetConverterFactory::new, () -> null, () -> null); + proto = new ScmTransportProtocol(GitTestHelper::createConverterFactory, () -> null, () -> null); Transport.register(proto); } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/BindTransportProtocolRule.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/BindTransportProtocolRule.java index 7e48c10b8c..f859efecae 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/BindTransportProtocolRule.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/BindTransportProtocolRule.java @@ -29,6 +29,7 @@ import org.eclipse.jgit.transport.Transport; import org.junit.rules.ExternalResource; import sonia.scm.repository.GitChangesetConverterFactory; import sonia.scm.repository.GitRepositoryHandler; +import sonia.scm.repository.GitTestHelper; import sonia.scm.repository.PreProcessorUtil; import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.api.HookContextFactory; @@ -43,12 +44,12 @@ public class BindTransportProtocolRule extends ExternalResource { private ScmTransportProtocol scmTransportProtocol; @Override - protected void before() throws Throwable { + protected void before() { HookContextFactory hookContextFactory = new HookContextFactory(mock(PreProcessorUtil.class)); RepositoryManager repositoryManager = mock(RepositoryManager.class); HookEventFacade hookEventFacade = new HookEventFacade(of(repositoryManager), hookContextFactory); GitRepositoryHandler gitRepositoryHandler = mock(GitRepositoryHandler.class); - scmTransportProtocol = new ScmTransportProtocol(of(new GitChangesetConverterFactory()), of(hookEventFacade), of(gitRepositoryHandler)); + scmTransportProtocol = new ScmTransportProtocol(of(GitTestHelper.createConverterFactory()), of(hookEventFacade), of(gitRepositoryHandler)); Transport.register(scmTransportProtocol); diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitIncomingCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitIncomingCommandTest.java index edf2500e1e..5cfab8de24 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitIncomingCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitIncomingCommandTest.java @@ -33,6 +33,7 @@ import org.junit.Test; import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; import sonia.scm.repository.ChangesetPagingResult; import sonia.scm.repository.GitChangesetConverterFactory; +import sonia.scm.repository.GitTestHelper; import sonia.scm.store.InMemoryConfigurationStoreFactory; import java.io.IOException; @@ -178,7 +179,7 @@ public class GitIncomingCommandTest return new GitIncomingCommand( new GitContext(incomingDirectory, incomingRepository, new GitRepositoryConfigStoreProvider(new InMemoryConfigurationStoreFactory())), handler, - new GitChangesetConverterFactory() + GitTestHelper.createConverterFactory() ); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandAncestorTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandAncestorTest.java index a6c08120e7..38c1516187 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandAncestorTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandAncestorTest.java @@ -28,6 +28,7 @@ import org.junit.Test; import sonia.scm.NotFoundException; import sonia.scm.repository.ChangesetPagingResult; import sonia.scm.repository.GitChangesetConverterFactory; +import sonia.scm.repository.GitTestHelper; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -110,6 +111,6 @@ public class GitLogCommandAncestorTest extends AbstractGitCommandTestBase } private GitLogCommand createCommand() { - return new GitLogCommand(createContext(), new GitChangesetConverterFactory()); + return new GitLogCommand(createContext(), GitTestHelper.createConverterFactory()); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java index df3fd2a702..b15c6ecebd 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java @@ -33,6 +33,7 @@ import sonia.scm.repository.Changeset; import sonia.scm.repository.ChangesetPagingResult; import sonia.scm.repository.GitChangesetConverterFactory; import sonia.scm.repository.GitRepositoryConfig; +import sonia.scm.repository.GitTestHelper; import sonia.scm.repository.Modifications; import sonia.scm.repository.Person; @@ -295,6 +296,6 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase } private GitLogCommand createCommand() { - return new GitLogCommand(createContext(), new GitChangesetConverterFactory()); + return new GitLogCommand(createContext(), GitTestHelper.createConverterFactory()); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitOutgoingCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitOutgoingCommandTest.java index 890704841a..22ccb94e1c 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitOutgoingCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitOutgoingCommandTest.java @@ -32,6 +32,7 @@ import org.junit.Test; import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider; import sonia.scm.repository.ChangesetPagingResult; import sonia.scm.repository.GitChangesetConverterFactory; +import sonia.scm.repository.GitTestHelper; import sonia.scm.store.InMemoryConfigurationStoreFactory; import java.io.IOException; @@ -155,7 +156,7 @@ public class GitOutgoingCommandTest extends AbstractRemoteCommandTestBase return new GitOutgoingCommand( new GitContext(outgoingDirectory, outgoingRepository, new GitRepositoryConfigStoreProvider(new InMemoryConfigurationStoreFactory())), handler, - new GitChangesetConverterFactory() + GitTestHelper.createConverterFactory() ); } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkingCopyFactoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkingCopyFactoryTest.java index c8762bde01..2d814bba2a 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkingCopyFactoryTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/SimpleGitWorkingCopyFactoryTest.java @@ -36,6 +36,7 @@ import org.junit.Test; import org.junit.rules.TemporaryFolder; import sonia.scm.repository.GitChangesetConverterFactory; import sonia.scm.repository.GitRepositoryHandler; +import sonia.scm.repository.GitTestHelper; import sonia.scm.repository.PreProcessorUtil; import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.api.HookContextFactory; @@ -66,7 +67,7 @@ public class SimpleGitWorkingCopyFactoryTest extends AbstractGitCommandTestBase HookContextFactory hookContextFactory = new HookContextFactory(mock(PreProcessorUtil.class)); HookEventFacade hookEventFacade = new HookEventFacade(of(mock(RepositoryManager.class)), hookContextFactory); GitRepositoryHandler gitRepositoryHandler = mock(GitRepositoryHandler.class); - proto = new ScmTransportProtocol(of(new GitChangesetConverterFactory()), of(hookEventFacade), of(gitRepositoryHandler)); + proto = new ScmTransportProtocol(of(GitTestHelper.createConverterFactory()), of(hookEventFacade), of(gitRepositoryHandler)); Transport.register(proto); workdirProvider = new WorkdirProvider(temporaryFolder.newFolder()); }