mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-01-05 07:09:48 +01:00
implementation and unit tests
This commit is contained in:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PartialRequest> 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/*
|
||||
*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
|
||||
@@ -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<RevCommit> doCommit(String message, Person author) {
|
||||
Optional<RevCommit> 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;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ abstract class GitMergeStrategy extends AbstractGitCommand.GitCloneWorker<MergeC
|
||||
private final ObjectId revisionToMerge;
|
||||
private final Person author;
|
||||
private final String messageTemplate;
|
||||
private final boolean signingDisabled;
|
||||
|
||||
GitMergeStrategy(Git clone, MergeCommandRequest request, GitContext context, sonia.scm.repository.Repository repository) {
|
||||
super(clone, context, repository);
|
||||
@@ -63,6 +64,7 @@ abstract class GitMergeStrategy extends AbstractGitCommand.GitCloneWorker<MergeC
|
||||
this.branchToMerge = request.getBranchToMerge();
|
||||
this.author = request.getAuthor();
|
||||
this.messageTemplate = request.getMessageTemplate();
|
||||
this.signingDisabled = request.isSigningDisabled();
|
||||
try {
|
||||
this.targetRevision = resolveRevision(request.getTargetBranch());
|
||||
this.revisionToMerge = resolveRevision(request.getBranchToMerge());
|
||||
@@ -88,7 +90,7 @@ abstract class GitMergeStrategy extends AbstractGitCommand.GitCloneWorker<MergeC
|
||||
|
||||
Optional<RevCommit> 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) {
|
||||
|
||||
@@ -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> revCommit = doCommit(request.getCommitMessage(), request.getAuthor());
|
||||
Optional<RevCommit> revCommit = doCommit(request.getCommitMessage(), request.getAuthor(), request.isSigningDisabled());
|
||||
push();
|
||||
return revCommit.orElseThrow(() -> new NoChangesMadeException(repository, ModifyWorker.this.request.getBranch())).name();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -50,15 +50,23 @@ public class RawGpgKey {
|
||||
private String displayName;
|
||||
private String owner;
|
||||
private String raw;
|
||||
private boolean readonly = false;
|
||||
private Set<Person> 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<Person> contacts, Instant created) {
|
||||
this.id = id;
|
||||
this.displayName = displayName;
|
||||
this.owner = owner;
|
||||
this.contacts = contacts;
|
||||
this.created = created;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
|
||||
@@ -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 <tricia.mcmillan@hitchhiker.org>");
|
||||
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> 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<PublicKey> 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));
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> rawKey = keyStore.getForUserId("testId");
|
||||
assertThat(rawKey).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void setForUserId() {
|
||||
keyStore.setForUserId("testId", "Test Key");
|
||||
final Optional<String> rawKey = keyStore.getForUserId("testId");
|
||||
assertThat(rawKey).isNotEmpty();
|
||||
assertThat(rawKey.get()).isEqualTo("Test Key");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
-----BEGIN PGP PRIVATE KEY BLOCK-----
|
||||
|
||||
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
|
||||
8kaUFg==
|
||||
=Vt5A
|
||||
-----END PGP PRIVATE KEY BLOCK-----
|
||||
Reference in New Issue
Block a user