implementation and unit tests

This commit is contained in:
Konstantin Schaper
2020-08-05 13:02:02 +02:00
parent 08a025ba81
commit 7072761ba1
18 changed files with 599 additions and 65 deletions

View File

@@ -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.
*

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
/*
*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

@@ -0,0 +1,111 @@
-----BEGIN PGP PRIVATE KEY BLOCK-----
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
8kaUFg==
=Vt5A
-----END PGP PRIVATE KEY BLOCK-----