diff --git a/lerna.json b/lerna.json
index dd2664e01c..3111260147 100644
--- a/lerna.json
+++ b/lerna.json
@@ -5,5 +5,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
- "version": "2.3.0"
+ "version": "2.4.0-SNAPSHOT"
}
diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java
index bd94f9e33f..2a3db03b67 100644
--- a/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java
+++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java
@@ -30,6 +30,7 @@ import de.otto.edison.hal.Links;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
+import sonia.scm.repository.Signature;
import java.time.Instant;
import java.util.List;
@@ -61,6 +62,8 @@ public class ChangesetDto extends HalRepresentation {
private List contributors;
+ private List signatures;
+
public ChangesetDto(Links links, Embedded embedded) {
super(links, embedded);
}
diff --git a/scm-core/src/main/java/sonia/scm/repository/Signature.java b/scm-core/src/main/java/sonia/scm/repository/Signature.java
index b077793f7b..0f9a4f996a 100644
--- a/scm-core/src/main/java/sonia/scm/repository/Signature.java
+++ b/scm-core/src/main/java/sonia/scm/repository/Signature.java
@@ -28,9 +28,11 @@ import lombok.Value;
import java.io.Serializable;
import java.util.Optional;
+import java.util.Set;
/**
* Signature is the output of a signature verification.
+ *
* @since 2.4.0
*/
@Value
@@ -40,8 +42,9 @@ public class Signature implements Serializable {
private final String keyId;
private final String type;
- private final boolean verified;
+ private final SignatureStatus status;
private final String owner;
+ private final Set contacts;
public Optional getOwner() {
return Optional.ofNullable(owner);
diff --git a/scm-core/src/main/java/sonia/scm/repository/SignatureStatus.java b/scm-core/src/main/java/sonia/scm/repository/SignatureStatus.java
new file mode 100644
index 0000000000..d8784c7a71
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/repository/SignatureStatus.java
@@ -0,0 +1,29 @@
+/*
+ * 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;
+
+public enum SignatureStatus {
+ VERIFIED, NOT_FOUND, INVALID;
+}
diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java
index 31ca9ca15d..fdc936af1f 100644
--- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java
+++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java
@@ -55,6 +55,7 @@ import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.repository.spi.RepositoryServiceProvider;
import sonia.scm.repository.spi.RepositoryServiceResolver;
import sonia.scm.repository.work.WorkdirProvider;
+import sonia.scm.security.PublicKeyDeletedEvent;
import sonia.scm.security.ScmSecurityException;
import java.util.Set;
@@ -100,14 +101,12 @@ import static sonia.scm.NotFoundException.notFound;
*
*
* @author Sebastian Sdorra
- * @since 1.17
- *
* @apiviz.landmark
* @apiviz.uses sonia.scm.repository.api.RepositoryService
+ * @since 1.17
*/
@Singleton
-public final class RepositoryServiceFactory
-{
+public final class RepositoryServiceFactory {
/**
* the logger for RepositoryServiceFactory
@@ -122,12 +121,11 @@ public final class RepositoryServiceFactory
* should not be called manually, it should only be used by the injection
* container.
*
- *
- * @param configuration configuration
- * @param cacheManager cache manager
+ * @param configuration configuration
+ * @param cacheManager cache manager
* @param repositoryManager manager for repositories
- * @param resolvers a set of {@link RepositoryServiceResolver}
- * @param preProcessorUtil helper object for pre processor handling
+ * @param resolvers a set of {@link RepositoryServiceResolver}
+ * @param preProcessorUtil helper object for pre processor handling
* @param protocolProviders
* @param workdirProvider
* @since 1.21
@@ -136,8 +134,7 @@ public final class RepositoryServiceFactory
public RepositoryServiceFactory(ScmConfiguration configuration,
CacheManager cacheManager, RepositoryManager repositoryManager,
Set resolvers, PreProcessorUtil preProcessorUtil,
- Set protocolProviders, WorkdirProvider workdirProvider)
- {
+ Set protocolProviders, WorkdirProvider workdirProvider) {
this(
configuration, cacheManager, repositoryManager, resolvers,
preProcessorUtil, protocolProviders, workdirProvider, ScmEventBus.getInstance()
@@ -146,11 +143,10 @@ public final class RepositoryServiceFactory
@VisibleForTesting
RepositoryServiceFactory(ScmConfiguration configuration,
- CacheManager cacheManager, RepositoryManager repositoryManager,
- Set resolvers, PreProcessorUtil preProcessorUtil,
- Set protocolProviders, WorkdirProvider workdirProvider,
- ScmEventBus eventBus)
- {
+ CacheManager cacheManager, RepositoryManager repositoryManager,
+ Set resolvers, PreProcessorUtil preProcessorUtil,
+ Set protocolProviders, WorkdirProvider workdirProvider,
+ ScmEventBus eventBus) {
this.configuration = configuration;
this.cacheManager = cacheManager;
this.repositoryManager = repositoryManager;
@@ -167,19 +163,16 @@ public final class RepositoryServiceFactory
/**
* Creates a new RepositoryService for the given repository.
*
- *
* @param repositoryId id of the repository
- *
* @return a implementation of RepositoryService
- * for the given type of repository
- *
- * @throws NotFoundException if no repository
- * with the given id is available
+ * for the given type of repository
+ * @throws NotFoundException if no repository
+ * with the given id is available
* @throws RepositoryServiceNotFoundException if no repository service
- * implementation for this kind of repository is available
- * @throws IllegalArgumentException if the repository id is null or empty
- * @throws ScmSecurityException if current user has not read permissions
- * for that repository
+ * implementation for this kind of repository is available
+ * @throws IllegalArgumentException if the repository id is null or empty
+ * @throws ScmSecurityException if current user has not read permissions
+ * for that repository
*/
public RepositoryService create(String repositoryId) {
Preconditions.checkArgument(!Strings.isNullOrEmpty(repositoryId),
@@ -187,8 +180,7 @@ public final class RepositoryServiceFactory
Repository repository = repositoryManager.get(repositoryId);
- if (repository == null)
- {
+ if (repository == null) {
throw new NotFoundException(Repository.class, repositoryId);
}
@@ -198,29 +190,24 @@ public final class RepositoryServiceFactory
/**
* Creates a new RepositoryService for the given repository.
*
- *
* @param namespaceAndName namespace and name of the repository
- *
* @return a implementation of RepositoryService
- * for the given type of repository
- *
- * @throws NotFoundException if no repository
- * with the given id is available
+ * for the given type of repository
+ * @throws NotFoundException if no repository
+ * with the given id is available
* @throws RepositoryServiceNotFoundException if no repository service
- * implementation for this kind of repository is available
- * @throws IllegalArgumentException if one of the parameters is null or empty
- * @throws ScmSecurityException if current user has not read permissions
- * for that repository
+ * implementation for this kind of repository is available
+ * @throws IllegalArgumentException if one of the parameters is null or empty
+ * @throws ScmSecurityException if current user has not read permissions
+ * for that repository
*/
- public RepositoryService create(NamespaceAndName namespaceAndName)
- {
+ public RepositoryService create(NamespaceAndName namespaceAndName) {
Preconditions.checkArgument(namespaceAndName != null,
"a non empty namespace and name is required");
Repository repository = repositoryManager.get(namespaceAndName);
- if (repository == null)
- {
+ if (repository == null) {
throw notFound(entity(namespaceAndName));
}
@@ -230,20 +217,16 @@ public final class RepositoryServiceFactory
/**
* Creates a new RepositoryService for the given repository.
*
- *
* @param repository the repository
- *
* @return a implementation of RepositoryService
- * for the given type of repository
- *
+ * for the given type of repository
* @throws RepositoryServiceNotFoundException if no repository service
- * implementation for this kind of repository is available
- * @throws NullPointerException if the repository is null
- * @throws ScmSecurityException if current user has not read permissions
- * for that repository
+ * implementation for this kind of repository is available
+ * @throws NullPointerException if the repository is null
+ * @throws ScmSecurityException if current user has not read permissions
+ * for that repository
*/
- public RepositoryService create(Repository repository)
- {
+ public RepositoryService create(Repository repository) {
Preconditions.checkNotNull(repository, "repository is required");
// check for read permissions of current user
@@ -251,14 +234,11 @@ public final class RepositoryServiceFactory
RepositoryService service = null;
- for (RepositoryServiceResolver resolver : resolvers)
- {
+ for (RepositoryServiceResolver resolver : resolvers) {
RepositoryServiceProvider provider = resolver.resolve(repository);
- if (provider != null)
- {
- if (logger.isDebugEnabled())
- {
+ if (provider != null) {
+ if (logger.isDebugEnabled()) {
logger.debug(
"create new repository service for repository {} of type {}",
repository.getName(), repository.getType());
@@ -271,8 +251,7 @@ public final class RepositoryServiceFactory
}
}
- if (service == null)
- {
+ if (service == null) {
throw new RepositoryServiceNotFoundException(repository);
}
@@ -284,8 +263,7 @@ public final class RepositoryServiceFactory
/**
* Hook and listener to clear all relevant repository caches.
*/
- private static class CacheClearHook
- {
+ private static class CacheClearHook {
private final Set> caches = Sets.newHashSet();
private final CacheManager cacheManager;
@@ -296,8 +274,7 @@ public final class RepositoryServiceFactory
*
* @param cacheManager cache manager
*/
- public CacheClearHook(CacheManager cacheManager)
- {
+ public CacheClearHook(CacheManager cacheManager) {
this.cacheManager = cacheManager;
this.caches.add(cacheManager.getCache(BlameCommandBuilder.CACHE_NAME));
this.caches.add(cacheManager.getCache(BrowseCommandBuilder.CACHE_NAME));
@@ -324,12 +301,10 @@ public final class RepositoryServiceFactory
* @param event hook event
*/
@Subscribe(referenceType = ReferenceType.STRONG)
- public void onEvent(PostReceiveRepositoryHookEvent event)
- {
+ public void onEvent(PostReceiveRepositoryHookEvent event) {
Repository repository = event.getRepository();
- if (repository != null)
- {
+ if (repository != null) {
String id = repository.getId();
clearCaches(id);
@@ -342,10 +317,8 @@ public final class RepositoryServiceFactory
* @param event repository event
*/
@Subscribe(referenceType = ReferenceType.STRONG)
- public void onEvent(RepositoryEvent event)
- {
- if (event.getEventType() == HandlerEventType.DELETE)
- {
+ public void onEvent(RepositoryEvent event) {
+ if (event.getEventType() == HandlerEventType.DELETE) {
clearCaches(event.getItem().getId());
}
}
@@ -357,11 +330,14 @@ public final class RepositoryServiceFactory
cacheManager.getCache(BranchesCommandBuilder.CACHE_NAME).removeAll(predicate);
}
+ @Subscribe
+ public void onEvent(PublicKeyDeletedEvent event) {
+ cacheManager.getCache(LogCommandBuilder.CACHE_NAME).clear();
+ }
+
@SuppressWarnings({"unchecked", "java:S3740", "rawtypes"})
- private void clearCaches(final String repositoryId)
- {
- if (logger.isDebugEnabled())
- {
+ private void clearCaches(final String repositoryId) {
+ if (logger.isDebugEnabled()) {
logger.debug("clear caches for repository id {}", repositoryId);
}
@@ -375,19 +351,29 @@ public final class RepositoryServiceFactory
//~--- fields ---------------------------------------------------------------
- /** cache manager */
+ /**
+ * cache manager
+ */
private final CacheManager cacheManager;
- /** scm-manager configuration */
+ /**
+ * scm-manager configuration
+ */
private final ScmConfiguration configuration;
- /** pre processor util */
+ /**
+ * pre processor util
+ */
private final PreProcessorUtil preProcessorUtil;
- /** repository manager */
+ /**
+ * repository manager
+ */
private final RepositoryManager repositoryManager;
- /** service resolvers */
+ /**
+ * service resolvers
+ */
private final Set resolvers;
private Set protocolProviders;
diff --git a/scm-core/src/main/java/sonia/scm/security/PublicKey.java b/scm-core/src/main/java/sonia/scm/security/PublicKey.java
index 734b19afc1..6348f73509 100644
--- a/scm-core/src/main/java/sonia/scm/security/PublicKey.java
+++ b/scm-core/src/main/java/sonia/scm/security/PublicKey.java
@@ -27,6 +27,7 @@ package sonia.scm.security;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.Optional;
+import java.util.Set;
/**
* The public key can be used to verify signatures.
@@ -49,9 +50,17 @@ public interface PublicKey {
*/
Optional getOwner();
+ /**
+ * Returns the contacts of the publickey.
+ *
+ * @return owner or empty optional
+ */
+ Set getContacts();
+
/**
* Verifies that the signature is valid for the given data.
- * @param stream stream of data to verify
+ *
+ * @param stream stream of data to verify
* @param signature signature
* @return {@code true} if the signature is valid for the given data
*/
@@ -59,7 +68,8 @@ public interface PublicKey {
/**
* Verifies that the signature is valid for the given data.
- * @param data data to verify
+ *
+ * @param data data to verify
* @param signature signature
* @return {@code true} if the signature is valid for the given data
*/
diff --git a/scm-core/src/main/java/sonia/scm/security/PublicKeyDeletedEvent.java b/scm-core/src/main/java/sonia/scm/security/PublicKeyDeletedEvent.java
new file mode 100644
index 0000000000..f3579dc7b1
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/security/PublicKeyDeletedEvent.java
@@ -0,0 +1,35 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security;
+
+import sonia.scm.event.Event;
+
+/**
+ * This event is fired when a public key was removed from SCM-Manager.
+ * @since 2.4.0
+ */
+@Event
+public class PublicKeyDeletedEvent {
+}
diff --git a/scm-plugins/scm-git-plugin/package.json b/scm-plugins/scm-git-plugin/package.json
index 4c8fbd8cc0..0f9d2f7d9d 100644
--- a/scm-plugins/scm-git-plugin/package.json
+++ b/scm-plugins/scm-git-plugin/package.json
@@ -1,7 +1,7 @@
{
"name": "@scm-manager/scm-git-plugin",
"private": true,
- "version": "2.3.0",
+ "version": "2.4.0-SNAPSHOT",
"license": "MIT",
"main": "./src/main/js/index.ts",
"scripts": {
@@ -20,6 +20,6 @@
},
"prettier": "@scm-manager/prettier-config",
"dependencies": {
- "@scm-manager/ui-plugins": "^2.3.0"
+ "@scm-manager/ui-plugins": "^2.4.0-SNAPSHOT"
}
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java
index 779fab0dbf..4573c61d87 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java
@@ -26,6 +26,7 @@ package sonia.scm.repository;
//~--- non-JDK imports --------------------------------------------------------
+import com.google.common.base.Strings;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import org.eclipse.jgit.lib.ObjectId;
@@ -51,7 +52,6 @@ import java.util.Optional;
//~--- JDK imports ------------------------------------------------------------
/**
- *
* @author Sebastian Sdorra
*/
public class GitChangesetConverter implements Closeable {
@@ -137,11 +137,15 @@ public class GitChangesetConverter implements Closeable {
byte[] signature = Arrays.copyOfRange(raw, start, end);
String publicKeyId = gpg.findPublicKeyId(signature);
+ if (Strings.isNullOrEmpty(publicKeyId)) {
+ // key not found
+ return new Signature(publicKeyId, "gpg", SignatureStatus.NOT_FOUND, null, Collections.emptySet());
+ }
Optional publicKeyById = gpg.findPublicKey(publicKeyId);
if (!publicKeyById.isPresent()) {
// key not found
- return new Signature(publicKeyId, "gpg", false, null);
+ return new Signature(publicKeyId, "gpg", SignatureStatus.NOT_FOUND, null, Collections.emptySet());
}
PublicKey publicKey = publicKeyById.get();
@@ -159,7 +163,13 @@ public class GitChangesetConverter implements Closeable {
}
boolean verified = publicKey.verify(baos.toByteArray(), signature);
- return new Signature(publicKeyId, "gpg", verified, publicKey.getOwner().orElse(null));
+ return new Signature(
+ publicKeyId,
+ "gpg",
+ verified ? SignatureStatus.VERIFIED : SignatureStatus.INVALID,
+ publicKey.getOwner().orElse(null),
+ publicKey.getContacts()
+ );
}
public Person createPersonFor(PersonIdent personIndent) {
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitChangesetConverterTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitChangesetConverterTest.java
index 3a2a624fb5..fcaba7a211 100644
--- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitChangesetConverterTest.java
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitChangesetConverterTest.java
@@ -45,7 +45,6 @@ import org.eclipse.jgit.lib.GpgSignature;
import org.eclipse.jgit.lib.GpgSigner;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.CredentialsProvider;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
@@ -72,6 +71,7 @@ import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.Security;
+import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Optional;
@@ -165,7 +165,7 @@ class GitChangesetConverterTest {
when(gpg.findPublicKeyId(any())).thenReturn(identity);
Signature signature = addSignedCommitAndReturnSignature(identity);
- assertThat(signature).isEqualTo(new Signature(identity, "gpg", false, null));
+ assertThat(signature).isEqualTo(new Signature(identity, "gpg", SignatureStatus.NOT_FOUND, null, Collections.emptySet()));
}
@Test
@@ -175,7 +175,7 @@ class GitChangesetConverterTest {
setPublicKey(identity, owner, false);
Signature signature = addSignedCommitAndReturnSignature(identity);
- assertThat(signature).isEqualTo(new Signature(identity, "gpg", false, owner));
+ assertThat(signature).isEqualTo(new Signature(identity, "gpg", SignatureStatus.INVALID, owner, Collections.emptySet()));
}
@Test
@@ -185,7 +185,7 @@ class GitChangesetConverterTest {
setPublicKey(identity, owner, true);
Signature signature = addSignedCommitAndReturnSignature(identity);
- assertThat(signature).isEqualTo(new Signature(identity, "gpg", true, owner));
+ assertThat(signature).isEqualTo(new Signature(identity, "gpg", SignatureStatus.VERIFIED, owner, Collections.emptySet()));
}
@Test
@@ -241,6 +241,7 @@ class GitChangesetConverterTest {
}
+
private PGPKeyPair createKeyPair() throws PGPException, NoSuchProviderException, NoSuchAlgorithmException {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
// we use a small key size to speedup test, a much larger size should be used for production
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitTestHelper.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitTestHelper.java
index cdf70db55d..e9d36609cb 100644
--- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitTestHelper.java
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitTestHelper.java
@@ -30,6 +30,7 @@ import sonia.scm.security.PublicKey;
import java.util.Collections;
import java.util.Optional;
+import java.util.Set;
public final class GitTestHelper {
diff --git a/scm-plugins/scm-hg-plugin/package.json b/scm-plugins/scm-hg-plugin/package.json
index 237ce626e3..af7f6e41ad 100644
--- a/scm-plugins/scm-hg-plugin/package.json
+++ b/scm-plugins/scm-hg-plugin/package.json
@@ -1,7 +1,7 @@
{
"name": "@scm-manager/scm-hg-plugin",
"private": true,
- "version": "2.3.0",
+ "version": "2.4.0-SNAPSHOT",
"license": "MIT",
"main": "./src/main/js/index.ts",
"scripts": {
@@ -19,6 +19,6 @@
},
"prettier": "@scm-manager/prettier-config",
"dependencies": {
- "@scm-manager/ui-plugins": "^2.3.0"
+ "@scm-manager/ui-plugins": "^2.4.0-SNAPSHOT"
}
}
diff --git a/scm-plugins/scm-legacy-plugin/package.json b/scm-plugins/scm-legacy-plugin/package.json
index 4733ac70a7..badb215e52 100644
--- a/scm-plugins/scm-legacy-plugin/package.json
+++ b/scm-plugins/scm-legacy-plugin/package.json
@@ -1,7 +1,7 @@
{
"name": "@scm-manager/scm-legacy-plugin",
"private": true,
- "version": "2.3.0",
+ "version": "2.4.0-SNAPSHOT",
"license": "MIT",
"main": "./src/main/js/index.tsx",
"scripts": {
@@ -19,6 +19,6 @@
},
"prettier": "@scm-manager/prettier-config",
"dependencies": {
- "@scm-manager/ui-plugins": "^2.3.0"
+ "@scm-manager/ui-plugins": "^2.4.0-SNAPSHOT"
}
}
diff --git a/scm-plugins/scm-svn-plugin/package.json b/scm-plugins/scm-svn-plugin/package.json
index ca3b81cfed..907a9d670c 100644
--- a/scm-plugins/scm-svn-plugin/package.json
+++ b/scm-plugins/scm-svn-plugin/package.json
@@ -1,7 +1,7 @@
{
"name": "@scm-manager/scm-svn-plugin",
"private": true,
- "version": "2.3.0",
+ "version": "2.4.0-SNAPSHOT",
"license": "MIT",
"main": "./src/main/js/index.ts",
"scripts": {
@@ -19,6 +19,6 @@
},
"prettier": "@scm-manager/prettier-config",
"dependencies": {
- "@scm-manager/ui-plugins": "^2.3.0"
+ "@scm-manager/ui-plugins": "^2.4.0-SNAPSHOT"
}
}
diff --git a/scm-ui/ui-components/package.json b/scm-ui/ui-components/package.json
index 74dce43cd0..153dae9c17 100644
--- a/scm-ui/ui-components/package.json
+++ b/scm-ui/ui-components/package.json
@@ -1,6 +1,6 @@
{
"name": "@scm-manager/ui-components",
- "version": "2.3.0",
+ "version": "2.4.0-SNAPSHOT",
"description": "UI Components for SCM-Manager and its plugins",
"main": "src/index.ts",
"files": [
@@ -47,7 +47,7 @@
},
"dependencies": {
"@scm-manager/ui-extensions": "^2.1.0",
- "@scm-manager/ui-types": "^2.3.0",
+ "@scm-manager/ui-types": "^2.4.0-SNAPSHOT",
"classnames": "^2.2.6",
"date-fns": "^2.4.1",
"gitdiff-parser": "^0.1.2",
diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap
index 58725b1514..69584d643e 100644
--- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap
+++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap
@@ -1765,58 +1765,62 @@ exports[`Storyshots Changesets Co-Authors with avatar 1`] = `
-
- changeset.contributors.authoredBy
-
-
- SCM Administrator
-
-
- commaSeparatedList.lastDivider
-
- changeset.contributors.coAuthoredBy
-
-
+ changeset.contributors.authoredBy
+
-
+ SCM Administrator
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1931,47 +1935,51 @@ exports[`Storyshots Changesets Commiter and Co-Authors with avatar 1`] = `
-
- changeset.contributors.authoredBy
-
-
- SCM Administrator
-
- ,
- changeset.contributors.committedBy
-
-
-
-
-
- commaSeparatedList.lastDivider
-
- changeset.contributors.coAuthoredBy
-
-
-
-
-
-
+ changeset.contributors.authoredBy
+
+
+ SCM Administrator
+
+ ,
+ changeset.contributors.committedBy
+
+
+
+
+
+ commaSeparatedList.lastDivider
+
+ changeset.contributors.coAuthoredBy
+
+
+
+
+
+
+
@@ -2073,18 +2081,22 @@ exports[`Storyshots Changesets Default 1`] = `
-
- changeset.contributors.authoredBy
-
-
- SCM Administrator
-
-
+ changeset.contributors.authoredBy
+
+
+ SCM Administrator
+
+
+
@@ -2196,18 +2208,22 @@ exports[`Storyshots Changesets Replacements 1`] = `
-
- changeset.contributors.authoredBy
-
-
- SCM Administrator
-
-
+ changeset.contributors.authoredBy
+
+
+ SCM Administrator
+
+
+
@@ -2309,30 +2325,34 @@ exports[`Storyshots Changesets With Committer 1`] = `
-
- changeset.contributors.authoredBy
-
-
- SCM Administrator
-
-
- commaSeparatedList.lastDivider
-
- changeset.contributors.committedBy
-
-
- Zaphod Beeblebrox
-
-
-
+ changeset.contributors.authoredBy
+
+
+ SCM Administrator
+
+
+ commaSeparatedList.lastDivider
+
+ changeset.contributors.committedBy
+
+
+ Zaphod Beeblebrox
+
+
+
+
@@ -2434,39 +2454,43 @@ exports[`Storyshots Changesets With Committer and Co-Author 1`] = `
-
- changeset.contributors.authoredBy
-
-
- SCM Administrator
-
- ,
- changeset.contributors.committedBy
-
-
- Zaphod Beeblebrox
-
-
- commaSeparatedList.lastDivider
-
- changeset.contributors.coAuthoredBy
-
-
- Ford Prefect
-
-
-
+ changeset.contributors.authoredBy
+
+
+ SCM Administrator
+
+ ,
+ changeset.contributors.committedBy
+
+
+ Zaphod Beeblebrox
+
+
+ commaSeparatedList.lastDivider
+
+ changeset.contributors.coAuthoredBy
+
+
+ Ford Prefect
+
+
+
+
@@ -2581,18 +2605,22 @@ exports[`Storyshots Changesets With avatar 1`] = `
-
- changeset.contributors.authoredBy
-
-
- SCM Administrator
-
-
+ changeset.contributors.authoredBy
+
+
+ SCM Administrator
+
+
+
@@ -2694,31 +2722,35 @@ exports[`Storyshots Changesets With multiple Co-Authors 1`] = `
-
- changeset.contributors.authoredBy
-
-
- SCM Administrator
-
-
- commaSeparatedList.lastDivider
-
- changeset.contributors.coAuthoredBy
-
-
+ SCM Administrator
+
+
+ commaSeparatedList.lastDivider
+
+ changeset.contributors.coAuthoredBy
+
+
- changeset.contributors.more
-
-
-
+ >
+ changeset.contributors.more
+
+
+
+
diff --git a/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx b/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx
index 46a30a6fd4..65b9b2de4c 100644
--- a/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx
+++ b/scm-ui/ui-components/src/repos/changesets/ChangesetRow.tsx
@@ -35,6 +35,8 @@ import ChangesetAuthor from "./ChangesetAuthor";
import ChangesetTags from "./ChangesetTags";
import ChangesetButtonGroup from "./ChangesetButtonGroup";
import ChangesetDescription from "./ChangesetDescription";
+import SignatureIcon from "@scm-manager/ui-webapp/src/repos/components/changesets/SignatureIcon";
+import { Level } from "../..";
type Props = WithTranslation & {
repository: Repository;
@@ -79,6 +81,11 @@ const VCenteredChildColumn = styled.div`
justify-content: flex-end;
`;
+const FlexRow = styled.div`
+ display: flex;
+ flex-direction: row;
+`;
+
class ChangesetRow extends React.Component {
createChangesetId = (changeset: Changeset) => {
const { repository } = this.props;
@@ -101,7 +108,7 @@ class ChangesetRow extends React.Component {
-
+
@@ -119,24 +126,32 @@ class ChangesetRow extends React.Component {
-
+
-
+
-
-
-
+
+
+
+
+ {changeset?.signatures && changeset.signatures.length > 0 && (
+
+ )}
+
-
+
-
+
<1/>",
"tags": "Tags",
"diffNotSupported": "Diff des Changesets wird von diesem Repositorytyp nicht unterstützt",
+ "signedBy": "Signiert von",
+ "signatureStatus": "Status",
+ "keyId": "Schlüssel-ID",
+ "signatureVerified": "Verifiziert",
+ "signatureNotVerified": "Nicht verifiziert",
+ "signatureInvalid": "Ungültig",
"shortlink": {
"title": "Changeset {{id}} aus {{namespace}}/{{name}}"
},
diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json
index 09d54ff58a..93b846f7a8 100644
--- a/scm-ui/ui-webapp/public/locales/en/repos.json
+++ b/scm-ui/ui-webapp/public/locales/en/repos.json
@@ -87,6 +87,12 @@
"summary": "Changeset <0/> was committed <1/>",
"shortSummary": "Committed <0/> <1/>",
"tags": "Tags",
+ "signedBy": "Signed by",
+ "keyId": "Key ID",
+ "signatureStatus": "Status",
+ "signatureVerified": "verified",
+ "signatureNotVerified": "not verified",
+ "signatureInvalid": "invalid",
"shortlink": {
"title": "Changeset {{id}} of {{namespace}}/{{name}}"
},
diff --git a/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx b/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx
index 657c47249d..c9bc1c6b6b 100644
--- a/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx
+++ b/scm-ui/ui-webapp/src/repos/components/changesets/ChangesetDetails.tsx
@@ -26,7 +26,7 @@ import { Trans, useTranslation, WithTranslation, withTranslation } from "react-i
import classNames from "classnames";
import styled from "styled-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
-import { Changeset, Repository, Tag, ParentChangeset } from "@scm-manager/ui-types";
+import { Changeset, ParentChangeset, Repository, Tag } from "@scm-manager/ui-types";
import {
AvatarImage,
AvatarWrapper,
@@ -38,11 +38,12 @@ import {
changesets,
ChangesetTag,
DateFromNow,
- Level,
- Icon
+ Icon,
+ Level
} from "@scm-manager/ui-components";
import ContributorTable from "./ContributorTable";
import { Link as ReactLink } from "react-router-dom";
+import SignatureIcon from "./SignatureIcon";
type Props = WithTranslation & {
changeset: Changeset;
@@ -63,6 +64,10 @@ const TagsWrapper = styled.div`
}
`;
+const SignedIcon = styled(SignatureIcon)`
+ padding-left: 1rem;
+`;
+
const BottomMarginLevel = styled(Level)`
margin-bottom: 1rem !important;
`;
@@ -74,6 +79,11 @@ const countContributors = (changeset: Changeset) => {
return 1;
};
+const FlexRow = styled.div`
+ display: flex;
+ flex-direction: row;
+`;
+
const ContributorLine = styled.div`
display: flex;
cursor: pointer;
@@ -120,12 +130,19 @@ const SeparatedParents = styled.div`
const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => {
const [t] = useTranslation("repos");
const [open, setOpen] = useState(false);
+ const signatureIcon = changeset?.signatures && changeset.signatures.length > 0 && (
+
+ );
+
if (open) {
return (
- setOpen(!open)}>
- {t("changeset.contributors.list")}
-
+
+ setOpen(!open)} className="is-ellipsis-overflow">
+ {t("changeset.contributors.list")}
+
+ {signatureIcon}
+
);
@@ -133,9 +150,10 @@ const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => {
return (
<>
setOpen(!open)}>
-
+
+ {signatureIcon}
(
@@ -190,9 +208,9 @@ class ChangesetDetails extends React.Component {
-
+
-
+
diff --git a/scm-ui/ui-webapp/src/repos/components/changesets/SignatureIcon.tsx b/scm-ui/ui-webapp/src/repos/components/changesets/SignatureIcon.tsx
new file mode 100644
index 0000000000..7bd4148200
--- /dev/null
+++ b/scm-ui/ui-webapp/src/repos/components/changesets/SignatureIcon.tsx
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ */
+import React, { FC } from "react";
+import { Icon, Tooltip } from "@scm-manager/ui-components";
+import { useTranslation } from "react-i18next";
+//import { Signature } from "@scm-manager/ui-types";
+
+type Props = {
+ signatures: any[];
+ className: any;
+};
+
+const SignatureIcon: FC = ({ signatures, className }) => {
+ const [t] = useTranslation("repos");
+
+ const signature = signatures?.length > 0 ? signatures[0] : undefined;
+
+ const createTooltipMessage = () => {
+ let status;
+ if (signature.status === "VERIFIED") {
+ status = t("changeset.signatureVerified");
+ } else if (signature.status === "INVALID") {
+ status = t("changeset.signatureInvalid");
+ } else {
+ status = t("changeset.signatureNotVerified");
+ }
+
+ if (signature.status === "NOT_FOUND") {
+ return `${t("changeset.signatureStatus")}: ${status}`;
+ }
+
+ return `${t("changeset.signedBy")}: ${signature.owner}\n${t("changeset.keyId")}: ${signature.keyId}\n${t(
+ "changeset.signatureStatus"
+ )}: ${status}\n${t("changeset.keyKontacts")}: ${signature.contacts.map((contact: string) => `\n- ${contact}`)}`;
+ };
+
+ const getColor = () => {
+ if (signature.status === "VERIFIED") {
+ return "success";
+ }
+ if (signature.status === "INVALID") {
+ return "danger";
+ }
+ return undefined;
+ };
+
+ if (!signature) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default SignatureIcon;
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 792610e30c..ffbf41360f 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,7 +24,10 @@
package sonia.scm.security.gpg;
-import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.bcpg.ArmoredInputStream;
+import org.bouncycastle.openpgp.PGPObjectFactory;
+import org.bouncycastle.openpgp.PGPSignatureList;
+import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.security.GPG;
@@ -32,6 +35,7 @@ import sonia.scm.security.PrivateKey;
import sonia.scm.security.PublicKey;
import javax.inject.Inject;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
@@ -51,8 +55,11 @@ public class DefaultGPG implements GPG {
@Override
public String findPublicKeyId(byte[] signature) {
try {
- return Keys.resolveIdFromKey(new String(signature));
- } catch (PGPException | IOException e) {
+ ArmoredInputStream armoredInputStream = new ArmoredInputStream(new ByteArrayInputStream(signature));
+ PGPObjectFactory pgpObjectFactory = new PGPObjectFactory(armoredInputStream, new JcaKeyFingerprintCalculator());
+ PGPSignatureList signatures = (PGPSignatureList) pgpObjectFactory.nextObject();
+ return "0x" + Long.toHexString(signatures.get(0).getKeyID()).toUpperCase();
+ } catch (IOException e) {
LOG.error("Could not find public key id in signature");
}
return "";
@@ -61,7 +68,8 @@ public class DefaultGPG implements GPG {
@Override
public Optional findPublicKey(String id) {
Optional key = store.findById(id);
- return key.map(rawGpgKey -> new GpgKey(id, rawGpgKey.getOwner()));
+
+ return key.map(rawGpgKey -> new GpgKey(id, rawGpgKey.getOwner(), rawGpgKey.getRaw().getBytes()));
}
@Override
@@ -71,7 +79,7 @@ public class DefaultGPG implements GPG {
if (!keys.isEmpty()) {
return keys
.stream()
- .map(rawGpgKey -> new GpgKey(rawGpgKey.getId(), rawGpgKey.getOwner()))
+ .map(rawGpgKey -> new GpgKey(rawGpgKey.getId(), rawGpgKey.getOwner(), rawGpgKey.getRaw().getBytes()))
.collect(Collectors.toSet());
}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/GpgKey.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/GpgKey.java
index 26a903744b..9fdbfca991 100644
--- a/scm-webapp/src/main/java/sonia/scm/security/gpg/GpgKey.java
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/GpgKey.java
@@ -43,7 +43,9 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashSet;
import java.util.Optional;
+import java.util.Set;
public class GpgKey implements PublicKey {
@@ -51,10 +53,16 @@ public class GpgKey implements PublicKey {
private final String id;
private final String owner;
+ private final Set contacts = new LinkedHashSet<>();
- public GpgKey(String id, String owner) {
+ public GpgKey(String id, String owner, byte[] raw) {
this.id = id;
this.owner = owner;
+ try {
+ getPgpPublicKey(raw).getUserIDs().forEachRemaining(contacts::add);
+ } catch (IOException e) {
+ LOG.error("Could not find contacts in public key", e);
+ }
}
@Override
@@ -70,13 +78,16 @@ public class GpgKey implements PublicKey {
return Optional.of(owner);
}
+ @Override
+ public Set getContacts() {
+ return contacts;
+ }
+
@Override
public boolean verify(InputStream stream, byte[] signature) {
boolean verified = false;
try {
- ArmoredInputStream armoredInputStream = new ArmoredInputStream(new ByteArrayInputStream(signature));
- PGPObjectFactory pgpObjectFactory = new PGPObjectFactory(armoredInputStream, new JcaKeyFingerprintCalculator());
- PGPPublicKey publicKey = ((PGPPublicKeyRing) pgpObjectFactory.nextObject()).getPublicKey();
+ PGPPublicKey publicKey = getPgpPublicKey(signature);
PGPSignature pgpSignature = ((PGPSignature) publicKey.getSignatures().next());
PGPContentVerifierBuilderProvider provider = new JcaPGPContentVerifierBuilderProvider();
@@ -97,6 +108,12 @@ public class GpgKey implements PublicKey {
}
return verified;
}
+
+ private PGPPublicKey getPgpPublicKey(byte[] signature) throws IOException {
+ ArmoredInputStream armoredInputStream = new ArmoredInputStream(new ByteArrayInputStream(signature));
+ PGPObjectFactory pgpObjectFactory = new PGPObjectFactory(armoredInputStream, new JcaKeyFingerprintCalculator());
+ return ((PGPPublicKeyRing) pgpObjectFactory.nextObject()).getPublicKey();
+ }
}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/Keys.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/Keys.java
index 26f2d94403..50075c217f 100644
--- a/scm-webapp/src/main/java/sonia/scm/security/gpg/Keys.java
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/Keys.java
@@ -24,6 +24,7 @@
package sonia.scm.security.gpg;
+import lombok.Value;
import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.PGPPublicKey;
import org.bouncycastle.openpgp.PGPPublicKeyRing;
@@ -37,45 +38,71 @@ import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
import java.util.Locale;
-import java.util.stream.Collectors;
+import java.util.Set;
+import java.util.function.Function;
+@Value
final class Keys {
private static final KeyFingerPrintCalculator calculator = new JcaKeyFingerprintCalculator();
- private Keys() {}
+ private final String master;
+ private final Set subs;
- static String resolveIdFromKey(String rawKey) throws IOException, PGPException {
- List keys = collectKeys(rawKey);
- if (keys.size() > 1) {
- keys = keys.stream().filter(PGPPublicKey::isMasterKey).collect(Collectors.toList());
- }
- if (keys.isEmpty()) {
- throw new IllegalArgumentException("found multiple keys, but no master keys");
- }
- if (keys.size() > 1) {
- throw new IllegalArgumentException("found multiple master keys");
+ private Keys(String master, Set subs) {
+ this.master = master;
+ this.subs = subs;
+ }
+
+ static Keys resolve(String raw) {
+ return resolve(raw, Keys::collectKeys);
+ }
+
+ static Keys resolve(String raw, Function> parser) {
+ List parsedKeys = parser.apply(raw);
+
+ String master = null;
+ Set subs = new HashSet<>();
+
+ for (PGPPublicKey key : parsedKeys) {
+ if (key.isMasterKey()) {
+ if (master != null) {
+ throw new IllegalArgumentException("Found more than one master key");
+ }
+ master = createId(key);
+ } else {
+ subs.add(createId(key));
+ }
}
- PGPPublicKey pgpPublicKey = keys.get(0);
- return createId(pgpPublicKey);
+ if (master == null) {
+ throw new IllegalArgumentException("No master key found");
+ }
+
+ return new Keys(master, Collections.unmodifiableSet(subs));
}
private static String createId(PGPPublicKey pgpPublicKey) {
return "0x" + Long.toHexString(pgpPublicKey.getKeyID()).toUpperCase(Locale.ENGLISH);
}
- private static List collectKeys(String rawKey) throws IOException, PGPException {
- List publicKeys = new ArrayList<>();
- InputStream decoderStream = PGPUtil.getDecoderStream(new ByteArrayInputStream(rawKey.getBytes(StandardCharsets.UTF_8)));
- PGPPublicKeyRingCollection collection = new PGPPublicKeyRingCollection(decoderStream, calculator);
- for (PGPPublicKeyRing pgpPublicKeys : collection) {
- for (PGPPublicKey pgpPublicKey : pgpPublicKeys) {
- publicKeys.add(pgpPublicKey);
+ private static List collectKeys(String rawKey) {
+ try {
+ List publicKeys = new ArrayList<>();
+ InputStream decoderStream = PGPUtil.getDecoderStream(new ByteArrayInputStream(rawKey.getBytes(StandardCharsets.UTF_8)));
+ PGPPublicKeyRingCollection collection = new PGPPublicKeyRingCollection(decoderStream, calculator);
+ for (PGPPublicKeyRing pgpPublicKeys : collection) {
+ for (PGPPublicKey pgpPublicKey : pgpPublicKeys) {
+ publicKeys.add(pgpPublicKey);
+ }
}
+ return publicKeys;
+ } catch (IOException | PGPException ex) {
+ throw new GPGException("Failed to collect public keys", ex);
}
- return publicKeys;
}
}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/MasterKeyReference.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/MasterKeyReference.java
new file mode 100644
index 0000000000..86e5b1df38
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/MasterKeyReference.java
@@ -0,0 +1,42 @@
+/*
+ * 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 lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlRootElement;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@XmlAccessorType(XmlAccessType.FIELD)
+@XmlRootElement
+public class MasterKeyReference {
+ String masterKey;
+}
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 cbfa1b5302..ce3d756781 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
@@ -24,16 +24,16 @@
package sonia.scm.security.gpg;
-import org.bouncycastle.openpgp.PGPException;
import sonia.scm.ContextEntry;
+import sonia.scm.event.ScmEventBus;
import sonia.scm.security.NotPublicKeyException;
+import sonia.scm.security.PublicKeyDeletedEvent;
import sonia.scm.store.DataStore;
import sonia.scm.store.DataStoreFactory;
import sonia.scm.user.UserPermissions;
import javax.inject.Inject;
import javax.inject.Singleton;
-import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@@ -43,31 +43,39 @@ import java.util.stream.Collectors;
public class PublicKeyStore {
private static final String STORE_NAME = "gpg_public_keys";
+ private static final String SUBKEY_STORE_NAME = "gpg_public_sub_keys";
private final DataStore store;
+ private final DataStore subKeyStore;
+ private final ScmEventBus eventBus;
@Inject
- public PublicKeyStore(DataStoreFactory dataStoreFactory) {
+ public PublicKeyStore(DataStoreFactory dataStoreFactory, ScmEventBus eventBus) {
this.store = dataStoreFactory.withType(RawGpgKey.class).withName(STORE_NAME).build();
+ this.subKeyStore = dataStoreFactory.withType(MasterKeyReference.class).withName(SUBKEY_STORE_NAME).build();
+ this.eventBus = eventBus;
}
public RawGpgKey add(String displayName, String username, String rawKey) {
- UserPermissions.modify(username).check();
+ UserPermissions.changePublicKeys(username).check();
if (!rawKey.contains("PUBLIC KEY")) {
throw new NotPublicKeyException(ContextEntry.ContextBuilder.entity(RawGpgKey.class, displayName).build(), "The provided key is not a public key");
}
- try {
- String id = Keys.resolveIdFromKey(rawKey);
- RawGpgKey key = new RawGpgKey(id, displayName, username, rawKey, Instant.now());
+ Keys keys = Keys.resolve(rawKey);
+ String master = keys.getMaster();
- store.put(id, key);
-
- return key;
- } catch (IOException | PGPException e) {
- throw new GPGException("failed to resolve id from gpg key");
+ for (String subKey : keys.getSubs()) {
+ subKeyStore.put(subKey, new MasterKeyReference(master));
}
+
+ RawGpgKey key = new RawGpgKey(master, displayName, username, rawKey, Instant.now());
+
+ store.put(master, key);
+
+ return key;
+
}
public void delete(String id) {
@@ -75,10 +83,17 @@ public class PublicKeyStore {
if (rawGpgKey != null) {
UserPermissions.modify(rawGpgKey.getOwner()).check();
store.remove(id);
+ eventBus.post(new PublicKeyDeletedEvent());
}
}
public Optional findById(String id) {
+ Optional reference = subKeyStore.getOptional(id);
+
+ if (reference.isPresent()) {
+ return store.getOptional(reference.get().getMasterKey());
+ }
+
return store.getOptional(id);
}
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 63f39bd523..98cff24a35 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
@@ -35,6 +35,7 @@ import sonia.scm.security.PublicKey;
import java.io.IOException;
import java.time.Instant;
import java.util.Optional;
+import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@@ -50,15 +51,16 @@ class DefaultGPGTest {
@Test
void shouldFindIdInSignature() throws IOException {
- String raw = GPGTestHelper.readKey("single.asc");
+ String raw = GPGTestHelper.readKey("signature.asc");
String publicKeyId = gpg.findPublicKeyId(raw.getBytes());
- assertThat(publicKeyId).isEqualTo("0x975922F193B07D6E");
+ assertThat(publicKeyId).isEqualTo("0x1F17B79A09DAD5B9");
}
@Test
- void shouldFindPublicKey() {
- RawGpgKey key1 = new RawGpgKey("42", "key_42", "trillian", "raw", Instant.now());
+ void shouldFindPublicKey() throws IOException {
+ String raw = GPGTestHelper.readKey("subkeys.asc");
+ RawGpgKey key1 = new RawGpgKey("42", "key_42", "trillian", raw, Instant.now());
when(store.findById("42")).thenReturn(Optional.of(key1));
@@ -68,12 +70,17 @@ class DefaultGPGTest {
assertThat(publicKey.get().getOwner()).isPresent();
assertThat(publicKey.get().getOwner().get()).contains("trillian");
assertThat(publicKey.get().getId()).isEqualTo("42");
+ assertThat(publicKey.get().getContacts()).contains("Sebastian Sdorra ",
+ "Sebastian Sdorra ");
}
@Test
- void shouldFindKeysForUsername() {
- RawGpgKey key1 = new RawGpgKey("1", "1", "trillian", "raw", Instant.now());
- RawGpgKey key2 = new RawGpgKey("2", "2", "trillian", "raw", Instant.now());
+ void shouldFindKeysForUsername() throws IOException {
+ String raw = GPGTestHelper.readKey("single.asc");
+ String raw2= GPGTestHelper.readKey("subkeys.asc");
+
+ RawGpgKey key1 = new RawGpgKey("1", "1", "trillian", raw, Instant.now());
+ RawGpgKey key2 = new RawGpgKey("2", "2", "trillian", raw2, Instant.now());
when(store.findByUsername("trillian")).thenReturn(ImmutableList.of(key1, key2));
Iterable keys = gpg.findPublicKeysByUsername("trillian");
diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/GpgKeyTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/GpgKeyTest.java
index 86bf5de4e0..6fb6a3ac69 100644
--- a/scm-webapp/src/test/java/sonia/scm/security/gpg/GpgKeyTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/GpgKeyTest.java
@@ -28,8 +28,6 @@ import org.junit.jupiter.api.Test;
import java.io.IOException;
-import static org.assertj.core.api.Assertions.assertThat;
-
class GpgKeyTest {
@Test
@@ -41,11 +39,11 @@ class GpgKeyTest {
byte[] raw = GPGTestHelper.readKey("subkeys.asc").getBytes();
- GpgKey key = new GpgKey("1", "trillian");
+ GpgKey key = new GpgKey("1", "trillian", raw);
boolean verified = key.verify(longContent.toString().getBytes(), raw);
- // assertThat(verified).isTrue();
+ // assertThat(verified).isTrue();
}
}
diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/KeysTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/KeysTest.java
index 765674d0f2..3bc97eca2a 100644
--- a/scm-webapp/src/test/java/sonia/scm/security/gpg/KeysTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/KeysTest.java
@@ -24,26 +24,58 @@
package sonia.scm.security.gpg;
-import org.bouncycastle.openpgp.PGPException;
+import com.google.common.collect.ImmutableList;
+import org.bouncycastle.openpgp.PGPPublicKey;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
+import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
import static sonia.scm.security.gpg.GPGTestHelper.readKey;
+@ExtendWith(MockitoExtension.class)
class KeysTest {
@Test
- void shouldResolveId() throws IOException, PGPException {
+ void shouldResolveSingleId() throws IOException {
String rawPublicKey = readKey("single.asc");
- assertThat(Keys.resolveIdFromKey(rawPublicKey)).isEqualTo("0x975922F193B07D6E");
+ Keys keys = Keys.resolve(rawPublicKey);
+ assertThat(keys.getMaster()).isEqualTo("0x975922F193B07D6E");
}
@Test
- void shouldResolveIdFromMasterKey() throws IOException, PGPException {
+ void shouldResolveIdsFromSubkeys() throws IOException {
String rawPublicKey = readKey("subkeys.asc");
- assertThat(Keys.resolveIdFromKey(rawPublicKey)).isEqualTo("0x13B13D4C8A9350A1");
+ Keys keys = Keys.resolve(rawPublicKey);
+ assertThat(keys.getMaster()).isEqualTo("0x13B13D4C8A9350A1");
+ assertThat(keys.getSubs()).containsOnly("0x247E908C6FD35473", "0xE50E1DD8B90D3A6B", "0xBF49759E43DD0E60");
+ }
+
+ @Test
+ void shouldThrowIllegalArgumentExceptionForMultipleMasterKeys() {
+ PGPPublicKey one = mockMasterKey(42L);
+ PGPPublicKey two = mockMasterKey(21L);
+
+ assertThrows(IllegalArgumentException.class, () -> Keys.resolve("", raw -> ImmutableList.of(one, two)));
+ }
+
+ @Test
+ void shouldThrowIllegalArgumentExceptionWithoutMasterKey() {
+ assertThrows(IllegalArgumentException.class, () -> Keys.resolve("", raw -> Collections.emptyList()));
+ }
+
+ private PGPPublicKey mockMasterKey(long id) {
+ PGPPublicKey key = mock(PGPPublicKey.class);
+ when(key.isMasterKey()).thenReturn(true);
+ lenient().when(key.getKeyID()).thenReturn(id);
+ return key;
}
}
diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyStoreTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyStoreTest.java
index 1b4e20cffc..1871827939 100644
--- a/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyStoreTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyStoreTest.java
@@ -33,7 +33,9 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import sonia.scm.event.ScmEventBus;
import sonia.scm.security.NotPublicKeyException;
+import sonia.scm.security.PublicKeyDeletedEvent;
import sonia.scm.store.DataStoreFactory;
import sonia.scm.store.InMemoryDataStoreFactory;
@@ -44,7 +46,9 @@ import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class PublicKeyStoreTest {
@@ -52,12 +56,15 @@ class PublicKeyStoreTest {
@Mock
private Subject subject;
+ @Mock
+ private ScmEventBus eventBus;
+
private PublicKeyStore keyStore;
private final DataStoreFactory dataStoreFactory = new InMemoryDataStoreFactory();
@BeforeEach
void setUpKeyStore() {
- keyStore = new PublicKeyStore(dataStoreFactory);
+ keyStore = new PublicKeyStore(dataStoreFactory, eventBus);
}
@BeforeEach
@@ -72,7 +79,7 @@ class PublicKeyStoreTest {
@Test
void shouldThrowAuthorizationExceptionOnAdd() throws IOException {
- doThrow(AuthorizationException.class).when(subject).checkPermission("user:modify:zaphod");
+ doThrow(AuthorizationException.class).when(subject).checkPermission("user:changePublicKeys:zaphod");
String rawKey = GPGTestHelper.readKey("single.asc");
assertThrows(AuthorizationException.class, () -> keyStore.add("zaphods key", "zaphod", rawKey));
@@ -118,6 +125,8 @@ class PublicKeyStoreTest {
key = keyStore.findById("0x975922F193B07D6E");
assertThat(key).isNotPresent();
+
+ verify(eventBus).post(any(PublicKeyDeletedEvent.class));
}
@Test
diff --git a/scm-webapp/src/test/resources/sonia/scm/security/gpg/signature.asc b/scm-webapp/src/test/resources/sonia/scm/security/gpg/signature.asc
new file mode 100644
index 0000000000..f3e756a16d
--- /dev/null
+++ b/scm-webapp/src/test/resources/sonia/scm/security/gpg/signature.asc
@@ -0,0 +1,16 @@
+-----BEGIN PGP SIGNATURE-----
+
+ iQIzBAABCgAdFiEEAibrQDifYryAFPjQHxe3mgna1bkFAl8gSFMACgkQHxe3mgna
+ 1bk0Hg/9HaN7aRkRo1FH5xPnswGFAidHG+XQBFYTK3EhR2g6m4iRCij58qIEMQVh
+ gV6FTtz6xGB/Oq32e5+gp9dYTp6lTrfm35hjg7uzwiC7OQx3QswZn7GUX/ZmuLk0
+ nqz4ryiVxeMWst47JkKAm9PY6GC+UITaL3tptNF//MBPAwEfNnDP7O667BTvnROh
+ 9XaSlIdYlbGxs5YzQK8BMB56YdutTDMtHl92oOjDA7r238dScmlNUSw3IQIsNPkz
+ 4WZwGM7HVxw7PjbRoMbwXbNEJ87F4SuBqhWM7BjHEUFS21wH8APtVZrNzd2znveq
+ oemn/+9pn0LG3Mg/2FASqHA6X+HS79YT9fb0O3HUvHzlaJmev88h4JSGcTJZG5+o
+ a9LEPW56clJYmCq/ghKAyV+bJfUkIAP9i75p4zi8Il4ACJnf9oVRg6RuOTXK5cnf
+ bvSzEtBWlXT1ELV52uo9gwcQMqgkQ89p8pTHcYHD3UhfdsuCfzlTGP/aQ/6OUBGg
+ k6AFS1kAwalAp2VEOBIJUXylM60VfdLaLfWpgg4T8mq4WIV3ROXTV0o2XciuF4r0
+ 2oXtyc84J7nnGJlJ4HqHBqzMNHF4giqVNhKQzBAQ1OEjePBfkW6RMDcYxkLilUrU
+ LzguRD1ybuS9xYQK60s2S68sNoAFkY8NTn9z8je1BO7ajzQn/fw=
+ =smHD
+ -----END PGP SIGNATURE-----
diff --git a/yarn.lock b/yarn.lock
index 2b7e82ef4d..742b29d0cd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -984,6 +984,13 @@
dependencies:
regenerator-runtime "^0.13.4"
+"@babel/runtime@^7.10.1":
+ version "7.10.5"
+ resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.5.tgz#303d8bd440ecd5a491eae6117fd3367698674c5c"
+ integrity sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg==
+ dependencies:
+ regenerator-runtime "^0.13.4"
+
"@babel/template@^7.10.1", "@babel/template@^7.3.3":
version "7.10.1"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.1.tgz#e167154a94cb5f14b28dc58f5356d2162f539811"
@@ -4917,10 +4924,10 @@ bulma-tooltip@^3.0.0:
resolved "https://registry.yarnpkg.com/bulma-tooltip/-/bulma-tooltip-3.0.2.tgz#2cf0abab1de2eba07f9d84eb7f07a8a88819ea92"
integrity sha512-CsT3APjhlZScskFg38n8HYL8oYNUHQtcu4sz6ERarxkUpBRbk9v0h/5KAvXeKapVSn2dp9l7bOGit5SECP8EWQ==
-bulma@^0.8.0:
- version "0.8.2"
- resolved "https://registry.yarnpkg.com/bulma/-/bulma-0.8.2.tgz#5d928f16ed4a84549c2873f95c92c38c69c631a7"
- integrity sha512-vMM/ijYSxX+Sm+nD7Lmc1UgWDy2JcL2nTKqwgEqXuOMU+IGALbXd5MLt/BcjBAPLIx36TtzhzBcSnOP974gcqA==
+bulma@^0.9.0:
+ version "0.9.0"
+ resolved "https://registry.yarnpkg.com/bulma/-/bulma-0.9.0.tgz#948c5445a49e9d7546f0826cb3820d17178a814f"
+ integrity sha512-rV75CJkubNUroAt0qCRkjznZLoaXq/ctfMXsMvKSL84UetbSyx5REl96e8GoQ04G4Tkw0XF3STECffTOQrbzOQ==
byline@^5.0.0:
version "5.0.0"
@@ -8656,12 +8663,12 @@ i18next@*:
dependencies:
"@babel/runtime" "^7.3.1"
-i18next@^17.3.0:
- version "17.3.1"
- resolved "https://registry.yarnpkg.com/i18next/-/i18next-17.3.1.tgz#5fe75e054aae39a6f38f1a79f7ab49184c6dc7a1"
- integrity sha512-4nY+yaENaoZKmpbiDXPzucVHCN3hN9Z9Zk7LyQXVOKVIpnYOJ3L/yxHJlBPtJDq3PGgjFwA0QBFm/26Z0iDT5A==
+i18next@^19.6.0:
+ version "19.6.3"
+ resolved "https://registry.yarnpkg.com/i18next/-/i18next-19.6.3.tgz#ce2346161b35c4c5ab691b0674119c7b349c0817"
+ integrity sha512-eYr98kw/C5z6kY21ti745p4IvbOJwY8F2T9tf/Lvy5lFnYRqE45+bppSgMPmcZZqYNT+xO0N0x6rexVR2wtZZQ==
dependencies:
- "@babel/runtime" "^7.3.1"
+ "@babel/runtime" "^7.10.1"
iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.13:
version "0.4.24"