From 7072761ba18794c8b1de4e3a204a90d45874670a Mon Sep 17 00:00:00 2001 From: Konstantin Schaper Date: Wed, 5 Aug 2020 13:02:02 +0200 Subject: [PATCH] implementation and unit tests --- .../repository/api/MergeCommandBuilder.java | 9 ++ .../repository/api/ModifyCommandBuilder.java | 6 +- .../repository/spi/MergeCommandRequest.java | 14 +- .../repository/spi/ModifyCommandRequest.java | 16 +-- .../java/sonia/scm/security/PrivateKey.java | 6 + .../sonia/scm/repository/ScmGpgSigner.java | 5 +- .../repository/ScmGpgSignerInitializer.java | 1 - .../repository/spi/AbstractGitCommand.java | 31 ++--- .../scm/repository/spi/GitMergeStrategy.java | 4 +- .../scm/repository/spi/GitModifyCommand.java | 3 +- .../scm/repository/ScmGpgSignerTest.java | 116 ++++++++++++++++ .../sonia/scm/security/gpg/DefaultGPG.java | 131 +++++++++++++++--- .../scm/security/gpg/PrivateKeyStore.java | 2 - .../scm/security/gpg/PublicKeyStore.java | 8 +- .../sonia/scm/security/gpg/RawGpgKey.java | 10 +- .../scm/security/gpg/DefaultGPGTest.java | 124 ++++++++++++++++- .../scm/security/gpg/PrivateKeyStoreTest.java | 67 +++++++++ .../sonia/scm/security/gpg/private-key.asc | 111 +++++++++++++++ 18 files changed, 599 insertions(+), 65 deletions(-) create mode 100644 scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/ScmGpgSignerTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/security/gpg/PrivateKeyStoreTest.java create mode 100644 scm-webapp/src/test/resources/sonia/scm/security/gpg/private-key.asc diff --git a/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java index f704b16b3d..406c2dc3bc 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java @@ -137,6 +137,15 @@ public class MergeCommandBuilder { return this; } + /** + * Disables adding a verifiable signature to the merge. + * @return This builder instance. + */ + public MergeCommandBuilder disableSigning() { + request.setSigningDisabled(true); + return this; + } + /** * Use this to set the strategy of the merge commit manually. * diff --git a/scm-core/src/main/java/sonia/scm/repository/api/ModifyCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/ModifyCommandBuilder.java index 56f2835ade..ad0800d5ce 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/ModifyCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/ModifyCommandBuilder.java @@ -165,11 +165,11 @@ public class ModifyCommandBuilder { } /** - * Set the branch the changes should be made upon. + * Disables adding a verifiable signature to the modification. * @return This builder instance. */ - public ModifyCommandBuilder disableSignature(String branch) { - request.setBranch(branch); + public ModifyCommandBuilder disableSigning() { + request.setSigningDisabled(true); return this; } diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommandRequest.java index 043f0f9648..773263057d 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommandRequest.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommandRequest.java @@ -43,6 +43,7 @@ public class MergeCommandRequest implements Validateable, Resetable, Serializabl private Person author; private String messageTemplate; private MergeStrategy mergeStrategy; + private boolean signingDisabled; public String getBranchToMerge() { return branchToMerge; @@ -84,6 +85,14 @@ public class MergeCommandRequest implements Validateable, Resetable, Serializabl this.mergeStrategy = mergeStrategy; } + public boolean isSigningDisabled() { + return signingDisabled; + } + + public void setSigningDisabled(boolean signingDisabled) { + this.signingDisabled = signingDisabled; + } + public boolean isValid() { return !Strings.isNullOrEmpty(getBranchToMerge()) && !Strings.isNullOrEmpty(getTargetBranch()); @@ -92,6 +101,7 @@ public class MergeCommandRequest implements Validateable, Resetable, Serializabl public void reset() { this.setBranchToMerge(null); this.setTargetBranch(null); + this.setSigningDisabled(false); } @Override @@ -109,7 +119,8 @@ public class MergeCommandRequest implements Validateable, Resetable, Serializabl return Objects.equal(branchToMerge, other.branchToMerge) && Objects.equal(targetBranch, other.targetBranch) && Objects.equal(author, other.author) - && Objects.equal(mergeStrategy, other.mergeStrategy); + && Objects.equal(mergeStrategy, other.mergeStrategy) + && Objects.equal(signingDisabled, other.signingDisabled); } @Override @@ -124,6 +135,7 @@ public class MergeCommandRequest implements Validateable, Resetable, Serializabl .add("targetBranch", targetBranch) .add("author", author) .add("mergeStrategy", mergeStrategy) + .add("signatureDisabled", signingDisabled) .toString(); } } diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommandRequest.java index 89e9b3844d..8ee00cfeef 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommandRequest.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommandRequest.java @@ -49,7 +49,7 @@ public class ModifyCommandRequest implements Resetable, Validateable, CommandWit private String branch; private String expectedRevision; private boolean defaultPath; - private boolean disableSigning; + private boolean signingDisabled; @Override public void reset() { @@ -58,7 +58,7 @@ public class ModifyCommandRequest implements Resetable, Validateable, CommandWit commitMessage = null; branch = null; defaultPath = false; - disableSigning = false; + signingDisabled = false; } public void addRequest(PartialRequest request) { @@ -77,6 +77,10 @@ public class ModifyCommandRequest implements Resetable, Validateable, CommandWit this.branch = branch; } + public void setSigningDisabled(boolean signingDisabled) { + this.signingDisabled = signingDisabled; + } + public List getRequests() { return Collections.unmodifiableList(requests); } @@ -114,12 +118,8 @@ public class ModifyCommandRequest implements Resetable, Validateable, CommandWit this.defaultPath = defaultPath; } - public boolean isDisableSigning() { - return disableSigning; - } - - public void setDisableSigning(boolean disableSigning) { - this.disableSigning = disableSigning; + public boolean isSigningDisabled() { + return signingDisabled; } public interface PartialRequest { diff --git a/scm-core/src/main/java/sonia/scm/security/PrivateKey.java b/scm-core/src/main/java/sonia/scm/security/PrivateKey.java index b1d12582a7..0ae639a3db 100644 --- a/scm-core/src/main/java/sonia/scm/security/PrivateKey.java +++ b/scm-core/src/main/java/sonia/scm/security/PrivateKey.java @@ -33,6 +33,12 @@ import java.io.InputStream; */ public interface PrivateKey { + /** + * Returns the key's id. + * @return id + */ + String getId(); + /** * Creates a signature for the given data. * @param stream data stream to sign diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSigner.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSigner.java index ebfa5d1a9f..423cd2aca8 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSigner.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSigner.java @@ -1,5 +1,4 @@ /* - * * MIT License * * Copyright (c) 2020-present Cloudogu GmbH and Contributors @@ -46,7 +45,7 @@ public class ScmGpgSigner extends GpgSigner { } @Override - public void sign(CommitBuilder commitBuilder, String s, PersonIdent personIdent, CredentialsProvider credentialsProvider) throws CanceledException { + public void sign(CommitBuilder commitBuilder, String keyId, PersonIdent personIdent, CredentialsProvider credentialsProvider) throws CanceledException { try { final byte[] signature = this.gpg.getPrivateKey().sign(commitBuilder.build()); commitBuilder.setGpgSignature(new GpgSignature(signature)); @@ -56,7 +55,7 @@ public class ScmGpgSigner extends GpgSigner { } @Override - public boolean canLocateSigningKey(String s, PersonIdent personIdent, CredentialsProvider credentialsProvider) throws CanceledException { + public boolean canLocateSigningKey(String keyId, PersonIdent personIdent, CredentialsProvider credentialsProvider) throws CanceledException { return true; } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSignerInitializer.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSignerInitializer.java index 4526615f39..144601275d 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSignerInitializer.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSignerInitializer.java @@ -1,5 +1,4 @@ /* - * * MIT License * * Copyright (c) 2020-present Cloudogu GmbH and Contributors diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java index d52d0a1574..48e9a71c86 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java @@ -63,11 +63,9 @@ import static sonia.scm.NotFoundException.notFound; //~--- JDK imports ------------------------------------------------------------ /** - * * @author Sebastian Sdorra */ -class AbstractGitCommand -{ +class AbstractGitCommand { /** * the logger for AbstractGitCommand @@ -77,11 +75,9 @@ class AbstractGitCommand /** * Constructs ... * - * @param context - * + * @param context */ - AbstractGitCommand(GitContext context) - { + AbstractGitCommand(GitContext context) { this.repository = context.getRepository(); this.context = context; } @@ -91,19 +87,16 @@ class AbstractGitCommand /** * Method description * - * * @return - * * @throws IOException */ - Repository open() throws IOException - { + Repository open() throws IOException { return context.open(); } ObjectId getCommitOrDefault(Repository gitRepository, String requestedCommit) throws IOException { ObjectId commit; - if ( Strings.isNullOrEmpty(requestedCommit) ) { + if (Strings.isNullOrEmpty(requestedCommit)) { commit = getDefaultBranch(gitRepository); } else { commit = gitRepository.resolve(requestedCommit); @@ -121,7 +114,7 @@ class AbstractGitCommand } Ref getBranchOrDefault(Repository gitRepository, String requestedBranch) throws IOException { - if ( Strings.isNullOrEmpty(requestedBranch) ) { + if (Strings.isNullOrEmpty(requestedBranch)) { String defaultBranchName = context.getConfig().getDefaultBranch(); if (!Strings.isNullOrEmpty(defaultBranchName)) { return GitUtil.getBranchId(gitRepository, defaultBranchName); @@ -226,7 +219,7 @@ class AbstractGitCommand } } - Optional doCommit(String message, Person author) { + Optional doCommit(String message, Person author, boolean signingDisabled) { Person authorToUse = determineAuthor(author); try { Status status = clone.status().call(); @@ -235,6 +228,8 @@ class AbstractGitCommand .setAuthor(authorToUse.getName(), authorToUse.getMail()) .setCommitter("SCM-Manager", "noreply@scm-manager.org") .setMessage(message) + .setSign(!signingDisabled) + .setSigningKey(signingDisabled ? null : "SCM-MANAGER-DEFAULT-KEY") .call()); } else { return empty(); @@ -294,9 +289,13 @@ class AbstractGitCommand //~--- fields --------------------------------------------------------------- - /** Field description */ + /** + * Field description + */ protected GitContext context; - /** Field description */ + /** + * Field description + */ protected sonia.scm.repository.Repository repository; } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeStrategy.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeStrategy.java index 0b79ec7204..0c58cf6aa1 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeStrategy.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeStrategy.java @@ -56,6 +56,7 @@ abstract class GitMergeStrategy extends AbstractGitCommand.GitCloneWorker doCommit() { logger.debug("merged branch {} into {}", branchToMerge, targetBranch); - return doCommit(MessageFormat.format(determineMessageTemplate(), branchToMerge, targetBranch), author); + return doCommit(MessageFormat.format(determineMessageTemplate(), branchToMerge, targetBranch), author, signingDisabled); } MergeCommandResult createSuccessResult(String newRevision) { diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java index 7f32f55e52..cd5fda2d0e 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java @@ -38,6 +38,7 @@ import sonia.scm.repository.GitRepositoryHandler; import sonia.scm.repository.GitWorkingCopyFactory; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Repository; +import sonia.scm.security.GPG; import sonia.scm.web.lfs.LfsBlobStoreFactory; import javax.inject.Inject; @@ -93,7 +94,7 @@ public class GitModifyCommand extends AbstractGitCommand implements ModifyComman r.execute(this); } failIfNotChanged(() -> new NoChangesMadeException(repository, ModifyWorker.this.request.getBranch())); - Optional revCommit = doCommit(request.getCommitMessage(), request.getAuthor()); + Optional revCommit = doCommit(request.getCommitMessage(), request.getAuthor(), request.isSigningDisabled()); push(); return revCommit.orElseThrow(() -> new NoChangesMadeException(repository, ModifyWorker.this.request.getBranch())).name(); } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/ScmGpgSignerTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/ScmGpgSignerTest.java new file mode 100644 index 0000000000..04f0a99c6a --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/ScmGpgSignerTest.java @@ -0,0 +1,116 @@ +/* + * 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.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.CanceledException; +import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription; +import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository; +import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.GpgSigner; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.security.GPG; +import sonia.scm.security.PrivateKey; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ScmGpgSignerTest { + + @Mock + GPG gpg; + + @Mock + PersonIdent personIdent; + + @Mock + CredentialsProvider credentialsProvider; + + private ScmGpgSigner signer; + + private final PrivateKey privateKey = new PrivateKey() { + @Override + public String getId() { + return "Private Key"; + } + + @Override + public byte[] sign(InputStream stream) { + return "MY FANCY SIGNATURE".getBytes(); + } + }; + + @BeforeEach + void beforeEach() { + signer = new ScmGpgSigner(gpg); + } + + @Test + void sign(@TempDir Path workdir) throws Exception { + + when(gpg.getPrivateKey()).thenReturn(privateKey); + + GpgSigner.setDefault(signer); + + Path repositoryPath = workdir.resolve("repository"); + Git git = Git.init().setDirectory(repositoryPath.toFile()).call(); + + Files.write(repositoryPath.resolve("README.md"), "# Hello".getBytes(StandardCharsets.UTF_8)); + git.add().addFilepattern("README.md").call(); + + git.commit() + .setAuthor("Bob The Signer", "sign@bob.de") + .setMessage("Signed from Bob") + .setSign(true) + .setSigningKey("Private Key") + .call(); + + RevCommit commit = git.log().setMaxCount(1).call().iterator().next(); + + final byte[] rawCommit = commit.getRawBuffer(); + final String commitString = new String(rawCommit); + assertThat(commitString).contains("gpgsig MY FANCY SIGNATURE"); + } + + @Test + void canLocateSigningKey() throws CanceledException { + assertThat(signer.canLocateSigningKey("foo", personIdent, credentialsProvider)).isTrue(); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultGPG.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultGPG.java index 8f592efb7f..4742fd7990 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultGPG.java +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultGPG.java @@ -24,30 +24,43 @@ package sonia.scm.security.gpg; -import com.sun.tools.javac.util.Pair; +import org.apache.commons.io.IOUtils; import org.apache.shiro.SecurityUtils; import org.bouncycastle.bcpg.ArmoredInputStream; +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.PGPEncryptedData; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPKeyPair; +import org.bouncycastle.openpgp.PGPKeyRing; import org.bouncycastle.openpgp.PGPKeyRingGenerator; import org.bouncycastle.openpgp.PGPObjectFactory; +import org.bouncycastle.openpgp.PGPPrivateKey; import org.bouncycastle.openpgp.PGPPublicKey; -import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.openpgp.PGPSignatureGenerator; import org.bouncycastle.openpgp.PGPSignatureList; import org.bouncycastle.openpgp.PGPUtil; +import org.bouncycastle.openpgp.jcajce.JcaPGPSecretKeyRingCollection; import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder; import org.bouncycastle.openpgp.operator.jcajce.JcaPGPKeyPair; +import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder; +import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyEncryptorBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.security.GPG; import sonia.scm.security.PrivateKey; import sonia.scm.security.PublicKey; -import sonia.scm.security.SessionId; -import sonia.scm.user.User; import javax.inject.Inject; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; @@ -61,6 +74,8 @@ import java.util.stream.Collectors; public class DefaultGPG implements GPG { private static final Logger LOG = LoggerFactory.getLogger(DefaultGPG.class); + static final String PRIVATE_KEY_ID = "SCM-KEY-ID"; + private final PublicKeyStore publicKeyStore; private final PrivateKeyStore privateKeyStore; @@ -111,31 +126,101 @@ public class DefaultGPG implements GPG { if (!privateRawKey.isPresent()) { try { - // Generate key pair - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC"); - keyPairGenerator.initialize(2048); + final PGPKeyRingGenerator keyPair = generateKeyPair(); - KeyPair pair = keyPairGenerator.generateKeyPair(); + final String rawPublicKey = exportKeyRing(keyPair.generatePublicKeyRing()); + final String rawPrivateKey = exportKeyRing(keyPair.generateSecretKeyRing()); - String identity = "0xAWESOMExBOB"; - PGPKeyPair keyPair = new JcaPGPKeyPair(PGPPublicKey.RSA_GENERAL, pair, new Date()); + privateKeyStore.setForUserId(userId, rawPrivateKey); + publicKeyStore.add("Default SCM-Manager Signing Key", userId, rawPublicKey, true); - new PGPKeyRingGenerator().generateSecretKeyRing().; + return new DefaultPrivateKey(rawPrivateKey); + } catch (PGPException | NoSuchAlgorithmException | NoSuchProviderException | IOException e) { + LOG.error("Private key could not be generated", e); + throw new IllegalStateException("Private key could not be generated", e); + } + } else { + return new DefaultPrivateKey(privateRawKey.get()); + } + } + + static PGPPrivateKey importPrivateKey(String rawKey) throws IOException, PGPException { + try (final InputStream decoderStream = PGPUtil.getDecoderStream(new ByteArrayInputStream(rawKey.getBytes()))) { + JcaPGPSecretKeyRingCollection secretKeyRingCollection = new JcaPGPSecretKeyRingCollection(decoderStream); + final PGPPrivateKey privateKey = secretKeyRingCollection.getKeyRings().next().getSecretKey().extractPrivateKey(new JcePBESecretKeyDecryptorBuilder().build(new char[]{})); + return privateKey; + } + } + + String exportKeyRing(PGPKeyRing keyRing) throws IOException { + final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + final ArmoredOutputStream armoredOutputStream = new ArmoredOutputStream(byteArrayOutputStream); + keyRing.encode(armoredOutputStream); + armoredOutputStream.close(); + return new String(byteArrayOutputStream.toByteArray()); + } + + PGPKeyRingGenerator generateKeyPair() throws PGPException, NoSuchProviderException, NoSuchAlgorithmException { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC"); + keyPairGenerator.initialize(2048); + + KeyPair pair = keyPairGenerator.generateKeyPair(); + + PGPKeyPair keyPair = new JcaPGPKeyPair(PGPPublicKey.RSA_GENERAL, pair, new Date()); + + return new PGPKeyRingGenerator( + PGPSignature.POSITIVE_CERTIFICATION, + keyPair, + PRIVATE_KEY_ID, + new JcaPGPDigestCalculatorProviderBuilder().build().get(HashAlgorithmTags.SHA1), + null, + null, + new JcaPGPContentSignerBuilder(PGPPublicKey.RSA_GENERAL, HashAlgorithmTags.SHA1), + new JcePBESecretKeyEncryptorBuilder(PGPEncryptedData.AES_256).build(new char[]{}) + ); + } + + static class DefaultPrivateKey implements PrivateKey { + + final PGPPrivateKey privateKey; + + DefaultPrivateKey(String rawPrivateKey) { + try { + privateKey = importPrivateKey(rawPrivateKey); + } catch (IOException | PGPException e) { + throw new IllegalStateException("Could not read private key", e); + } + } + + @Override + public String getId() { + return PRIVATE_KEY_ID; + } + + @Override + public byte[] sign(InputStream stream) { + + PGPSignatureGenerator signatureGenerator = new PGPSignatureGenerator( + new JcaPGPContentSignerBuilder( + PGPPublicKey.RSA_GENERAL, + HashAlgorithmTags.SHA1).setProvider(BouncyCastleProvider.PROVIDER_NAME) + ); + + try { + signatureGenerator.init(PGPSignature.BINARY_DOCUMENT, privateKey); } catch (PGPException e) { - e.printStackTrace(); - } catch (NoSuchAlgorithmException e) { - e.printStackTrace(); - } catch (NoSuchProviderException e) { - e.printStackTrace(); + throw new IllegalStateException("Could not initialize signature generator", e); } - // - privateKeyStore.setForUserId(user.getId(), privateKeyGpgKeyPair.fst); - publicKeyStore.add(user.getDisplayName(), user.getName(), privateKeyGpgKeyPair.snd.getRaw()); - return privateKeyGpgKeyPair.fst; - } else { -// PGPUtil.getDecoderStream(); - return privateRawKey.get(); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try (BCPGOutputStream out = new BCPGOutputStream(new ArmoredOutputStream(buffer))) { + signatureGenerator.update(IOUtils.toByteArray(stream)); + signatureGenerator.generate().encode(out); + } catch (PGPException | IOException e) { + throw new IllegalStateException("Could not create signature", e); + } + + return buffer.toByteArray(); } } } diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/PrivateKeyStore.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/PrivateKeyStore.java index 1684ea7a2c..1c18c9c10f 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/gpg/PrivateKeyStore.java +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/PrivateKeyStore.java @@ -20,10 +20,8 @@ * 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.gpg; import lombok.AllArgsConstructor; diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyStore.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyStore.java index 9ecf75d31e..a443d45443 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyStore.java +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyStore.java @@ -64,7 +64,11 @@ public class PublicKeyStore { this.eventBus = eventBus; } - public RawGpgKey add(String displayName, String username, String rawKey, ) { + public RawGpgKey add(String displayName, String username, String rawKey) { + return add(displayName, username, rawKey, false); + } + + public RawGpgKey add(String displayName, String username, String rawKey, boolean readonly) { UserPermissions.changePublicKeys(username).check(); if (!rawKey.contains("PUBLIC KEY")) { @@ -78,7 +82,7 @@ public class PublicKeyStore { subKeyStore.put(subKey, new MasterKeyReference(master)); } - RawGpgKey key = new RawGpgKey(master, displayName, username, rawKey, getContactsFromPublicKey(rawKey), Instant.now()); + RawGpgKey key = new RawGpgKey(master, displayName, username, rawKey, getContactsFromPublicKey(rawKey), Instant.now(), readonly); store.put(master, key); eventBus.post(new PublicKeyCreatedEvent()); diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKey.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKey.java index 420683efd4..b884a9c69a 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKey.java +++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKey.java @@ -50,15 +50,23 @@ public class RawGpgKey { private String displayName; private String owner; private String raw; - private boolean readonly = false; private Set contacts; @XmlJavaTypeAdapter(XmlInstantAdapter.class) private Instant created; + private boolean readonly; + RawGpgKey(String id) { this.id = id; } + RawGpgKey(String id, String displayName, String owner, String raw, Set contacts, Instant created) { + this.id = id; + this.displayName = displayName; + this.owner = owner; + this.contacts = contacts; + this.created = created; + } @Override public boolean equals(Object o) { diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultGPGTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultGPGTest.java index 924d18e859..c67e8cbbbf 100644 --- a/scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultGPGTest.java +++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultGPGTest.java @@ -26,27 +26,55 @@ package sonia.scm.security.gpg; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.mgt.DefaultSecurityManager; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPKeyRingGenerator; +import org.bouncycastle.openpgp.PGPPrivateKey; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.repository.Person; +import sonia.scm.security.PrivateKey; import sonia.scm.security.PublicKey; +import sonia.scm.util.MockUtil; import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.Security; import java.time.Instant; import java.util.Collections; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class DefaultGPGTest { + private static void registerBouncyCastleProviderIfNecessary() { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + @Mock - private PublicKeyStore store; + private PublicKeyStore publicKeyStore; + + @Mock + private PrivateKeyStore privateKeyStore; @InjectMocks private DefaultGPG gpg; @@ -65,7 +93,7 @@ class DefaultGPGTest { Person trillian = Person.toPerson("Trillian "); RawGpgKey key1 = new RawGpgKey("42", "key_42", "trillian", raw, ImmutableSet.of(trillian), Instant.now()); - when(store.findById("42")).thenReturn(Optional.of(key1)); + when(publicKeyStore.findById("42")).thenReturn(Optional.of(key1)); Optional publicKey = gpg.findPublicKey("42"); @@ -83,7 +111,7 @@ class DefaultGPGTest { RawGpgKey key1 = new RawGpgKey("1", "1", "trillian", raw, Collections.emptySet(), Instant.now()); RawGpgKey key2 = new RawGpgKey("2", "2", "trillian", raw2, Collections.emptySet(), Instant.now()); - when(store.findByUsername("trillian")).thenReturn(ImmutableList.of(key1, key2)); + when(publicKeyStore.findByUsername("trillian")).thenReturn(ImmutableList.of(key1, key2)); Iterable keys = gpg.findPublicKeysByUsername("trillian"); @@ -92,4 +120,94 @@ class DefaultGPGTest { assertThat(key.getOwner()).isPresent(); assertThat(key.getOwner().get()).contains("trillian"); } + + @Test + void shouldGenerateKeyPair() throws NoSuchProviderException, NoSuchAlgorithmException, PGPException { + registerBouncyCastleProviderIfNecessary(); + + final PGPKeyRingGenerator keyRingGenerator = gpg.generateKeyPair(); + assertThat(keyRingGenerator.generatePublicKeyRing().getPublicKey()).isNotNull(); + assertThat(keyRingGenerator.generateSecretKeyRing().getSecretKey()).isNotNull(); + } + + @Test + void shouldExportGeneratedKeyPair() throws NoSuchProviderException, NoSuchAlgorithmException, PGPException, IOException { + registerBouncyCastleProviderIfNecessary(); + + final PGPKeyRingGenerator keyRingGenerator = gpg.generateKeyPair(); + + final String exportedPublicKey = gpg.exportKeyRing(keyRingGenerator.generatePublicKeyRing()); + assertThat(exportedPublicKey).isNotBlank(); + assertThat(exportedPublicKey).startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----"); + assertThat(exportedPublicKey).contains("-----END PGP PUBLIC KEY BLOCK-----"); + + final String exportedPrivateKey = gpg.exportKeyRing(keyRingGenerator.generateSecretKeyRing()); + assertThat(exportedPrivateKey).isNotBlank(); + assertThat(exportedPrivateKey).startsWith("-----BEGIN PGP PRIVATE KEY BLOCK-----"); + assertThat(exportedPrivateKey).contains("-----END PGP PRIVATE KEY BLOCK-----"); + } + + @Test + void shouldImportKeyPair() throws IOException, PGPException { + String raw = GPGTestHelper.readResourceAsString("private-key.asc"); + final PGPPrivateKey privateKey = DefaultGPG.importPrivateKey(raw); + assertThat(privateKey).isNotNull(); + } + + @Test + void shouldImportExportedGeneratedPrivateKey() throws NoSuchProviderException, NoSuchAlgorithmException, PGPException, IOException { + registerBouncyCastleProviderIfNecessary(); + + final PGPKeyRingGenerator keyRingGenerator = gpg.generateKeyPair(); + final String exportedPrivateKey = gpg.exportKeyRing(keyRingGenerator.generateSecretKeyRing()); + final PGPPrivateKey privateKey = DefaultGPG.importPrivateKey(exportedPrivateKey); + assertThat(privateKey).isNotNull(); + } + + @Test + void shouldCreateSignature() throws IOException { + registerBouncyCastleProviderIfNecessary(); + + String raw = GPGTestHelper.readResourceAsString("private-key.asc"); + final DefaultGPG.DefaultPrivateKey privateKey = new DefaultGPG.DefaultPrivateKey(raw); + assertThat(privateKey.getId()).contains(DefaultGPG.PRIVATE_KEY_ID); + final byte[] signature = privateKey.sign("This is a test commit".getBytes()); + final String signatureString = new String(signature); + assertThat(signature).isNotEmpty(); + assertThat(signatureString).startsWith("-----BEGIN PGP SIGNATURE-----"); + assertThat(signatureString).contains("-----END PGP SIGNATURE-----"); + } + + @Test + void shouldReturnGeneratedPrivateKeyIfNoneStored() { + registerBouncyCastleProviderIfNecessary(); + + SecurityUtils.setSecurityManager(new DefaultSecurityManager()); + Subject subjectUnderTest = MockUtil.createUserSubject(SecurityUtils.getSecurityManager()); + ThreadContext.bind(subjectUnderTest); + + final PrivateKey privateKey = gpg.getPrivateKey(); + assertThat(privateKey).isNotNull(); + + verify(privateKeyStore, atLeastOnce()).setForUserId(eq(subjectUnderTest.getPrincipal().toString()), anyString()); + verify(publicKeyStore, atLeastOnce()).add(eq("Default SCM-Manager Signing Key"), eq(subjectUnderTest.getPrincipal().toString()), anyString(), eq(true)); + } + + @Test + void shouldReturnStoredPrivateKey() throws IOException { + registerBouncyCastleProviderIfNecessary(); + + SecurityUtils.setSecurityManager(new DefaultSecurityManager()); + Subject subjectUnderTest = MockUtil.createUserSubject(SecurityUtils.getSecurityManager()); + ThreadContext.bind(subjectUnderTest); + + String raw = GPGTestHelper.readResourceAsString("private-key.asc"); + when(privateKeyStore.getForUserId(subjectUnderTest.getPrincipal().toString())).thenReturn(Optional.of(raw)); + + final PrivateKey privateKey = gpg.getPrivateKey(); + assertThat(privateKey).isNotNull(); + verify(privateKeyStore, never()).setForUserId(eq(subjectUnderTest.getPrincipal().toString()), anyString()); + verify(publicKeyStore, never()).add(eq("Default SCM-Manager Signing Key"), eq(subjectUnderTest.getPrincipal().toString()), anyString(), eq(true)); + + } } diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/PrivateKeyStoreTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/PrivateKeyStoreTest.java new file mode 100644 index 0000000000..e4a42abe8f --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/PrivateKeyStoreTest.java @@ -0,0 +1,67 @@ +/* + * 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.gpg; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.store.DataStoreFactory; +import sonia.scm.store.InMemoryDataStoreFactory; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +class PrivateKeyStoreTest { + + + private DataStoreFactory dataStoreFactory; + private PrivateKeyStore keyStore; + + @BeforeEach + void setup() { + dataStoreFactory = new InMemoryDataStoreFactory(); + keyStore = new PrivateKeyStore(dataStoreFactory); + } + + @Test + void returnEmptyIfNotYetSet() { + final Optional rawKey = keyStore.getForUserId("testId"); + assertThat(rawKey).isEmpty(); + } + + @Test + void setForUserId() { + keyStore.setForUserId("testId", "Test Key"); + final Optional rawKey = keyStore.getForUserId("testId"); + assertThat(rawKey).isNotEmpty(); + assertThat(rawKey.get()).isEqualTo("Test Key"); + } +} diff --git a/scm-webapp/src/test/resources/sonia/scm/security/gpg/private-key.asc b/scm-webapp/src/test/resources/sonia/scm/security/gpg/private-key.asc new file mode 100644 index 0000000000..560e37e95c --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/security/gpg/private-key.asc @@ -0,0 +1,111 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- + +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 +8kaUFg== +=Vt5A +-----END PGP PRIVATE KEY BLOCK-----