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 + - Ford Prefect + SCM Administrator - - Zaphod Beeblebrox - - - Tricia Marie McMillan - - - -

+ + Ford Prefect + + + Zaphod Beeblebrox + + + Tricia Marie McMillan + + + +

+ @@ -1931,47 +1935,51 @@ exports[`Storyshots Changesets Commiter and Co-Authors with avatar 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 + + +

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