show signature key on changeset

This commit is contained in:
Eduard Heimbuch
2020-07-28 17:52:20 +02:00
parent 0c45cf21e3
commit b22ead23de
37 changed files with 806 additions and 385 deletions

View File

@@ -5,5 +5,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "2.3.0"
"version": "2.4.0-SNAPSHOT"
}

View File

@@ -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<ContributorDto> contributors;
private List<Signature> signatures;
public ChangesetDto(Links links, Embedded embedded) {
super(links, embedded);
}

View File

@@ -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<String> contacts;
public Optional<String> getOwner() {
return Optional.ofNullable(owner);

View File

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

View File

@@ -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;
* </code></pre>
*
* @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<RepositoryServiceResolver> resolvers, PreProcessorUtil preProcessorUtil,
Set<ScmProtocolProvider> protocolProviders, WorkdirProvider workdirProvider)
{
Set<ScmProtocolProvider> 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<RepositoryServiceResolver> resolvers, PreProcessorUtil preProcessorUtil,
Set<ScmProtocolProvider> protocolProviders, WorkdirProvider workdirProvider,
ScmEventBus eventBus)
{
CacheManager cacheManager, RepositoryManager repositoryManager,
Set<RepositoryServiceResolver> resolvers, PreProcessorUtil preProcessorUtil,
Set<ScmProtocolProvider> 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<Cache<?, ?>> 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<RepositoryServiceResolver> resolvers;
private Set<ScmProtocolProvider> protocolProviders;

View File

@@ -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<String> getOwner();
/**
* Returns the contacts of the publickey.
*
* @return owner or empty optional
*/
Set<String> 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
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1765,58 +1765,62 @@ exports[`Storyshots Changesets Co-Authors with avatar 1`] = `
<p
className="is-hidden-desktop"
/>
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
<div
className="ChangesetRow__FlexRow-tkpti5-7 fTLhSo"
>
changeset.contributors.authoredBy
<a
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
>
SCM Administrator
</a>
commaSeparatedList.lastDivider
changeset.contributors.coAuthoredBy
<span
className="ChangesetAuthor__AvatarList-sc-1oz0xgw-0 bpcCrU"
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
>
changeset.contributors.authoredBy
<a
href="mailto:ford.prefect@hitchhiker.com"
title="changeset.contributors.mailto ford.prefect@hitchhiker.com"
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
>
<img
alt="Ford Prefect"
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
src="https://robohash.org/ford.prefect@hitchhiker.com"
/>
SCM Administrator
</a>
<a
href="mailto:zaphod.beeblebrox@hitchhiker.cm"
title="changeset.contributors.mailto zaphod.beeblebrox@hitchhiker.cm"
commaSeparatedList.lastDivider
changeset.contributors.coAuthoredBy
<span
className="ChangesetAuthor__AvatarList-sc-1oz0xgw-0 bpcCrU"
>
<img
alt="Zaphod Beeblebrox"
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
src="https://robohash.org/zaphod.beeblebrox@hitchhiker.cm"
/>
</a>
<a
href="mailto:trillian@hitchhiker.cm"
title="changeset.contributors.mailto trillian@hitchhiker.cm"
>
<img
alt="Tricia Marie McMillan"
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
src="https://robohash.org/trillian@hitchhiker.cm"
/>
</a>
</span>
</p>
<a
href="mailto:ford.prefect@hitchhiker.com"
title="changeset.contributors.mailto ford.prefect@hitchhiker.com"
>
<img
alt="Ford Prefect"
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
src="https://robohash.org/ford.prefect@hitchhiker.com"
/>
</a>
<a
href="mailto:zaphod.beeblebrox@hitchhiker.cm"
title="changeset.contributors.mailto zaphod.beeblebrox@hitchhiker.cm"
>
<img
alt="Zaphod Beeblebrox"
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
src="https://robohash.org/zaphod.beeblebrox@hitchhiker.cm"
/>
</a>
<a
href="mailto:trillian@hitchhiker.cm"
title="changeset.contributors.mailto trillian@hitchhiker.cm"
>
<img
alt="Tricia Marie McMillan"
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
src="https://robohash.org/trillian@hitchhiker.cm"
/>
</a>
</span>
</p>
</div>
</div>
</div>
</div>
@@ -1931,47 +1935,51 @@ exports[`Storyshots Changesets Commiter and Co-Authors with avatar 1`] = `
<p
className="is-hidden-desktop"
/>
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
<div
className="ChangesetRow__FlexRow-tkpti5-7 fTLhSo"
>
changeset.contributors.authoredBy
<a
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
>
SCM Administrator
</a>
,
changeset.contributors.committedBy
<a
href="mailto:zaphod.beeblebrox@hitchhiker.cm"
title="changeset.contributors.mailto zaphod.beeblebrox@hitchhiker.cm"
>
<img
alt="Zaphod Beeblebrox"
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
src="https://robohash.org/zaphod.beeblebrox@hitchhiker.cm"
/>
</a>
commaSeparatedList.lastDivider
changeset.contributors.coAuthoredBy
<a
href="mailto:ford.prefect@hitchhiker.com"
title="changeset.contributors.mailto ford.prefect@hitchhiker.com"
>
<img
alt="Ford Prefect"
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
src="https://robohash.org/ford.prefect@hitchhiker.com"
/>
</a>
</p>
changeset.contributors.authoredBy
<a
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
>
SCM Administrator
</a>
,
changeset.contributors.committedBy
<a
href="mailto:zaphod.beeblebrox@hitchhiker.cm"
title="changeset.contributors.mailto zaphod.beeblebrox@hitchhiker.cm"
>
<img
alt="Zaphod Beeblebrox"
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
src="https://robohash.org/zaphod.beeblebrox@hitchhiker.cm"
/>
</a>
commaSeparatedList.lastDivider
changeset.contributors.coAuthoredBy
<a
href="mailto:ford.prefect@hitchhiker.com"
title="changeset.contributors.mailto ford.prefect@hitchhiker.com"
>
<img
alt="Ford Prefect"
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
src="https://robohash.org/ford.prefect@hitchhiker.com"
/>
</a>
</p>
</div>
</div>
</div>
</div>
@@ -2073,18 +2081,22 @@ exports[`Storyshots Changesets Default 1`] = `
<p
className="is-hidden-desktop"
/>
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
<div
className="ChangesetRow__FlexRow-tkpti5-7 fTLhSo"
>
changeset.contributors.authoredBy
<a
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
>
SCM Administrator
</a>
</p>
changeset.contributors.authoredBy
<a
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
>
SCM Administrator
</a>
</p>
</div>
</div>
</div>
</div>
@@ -2196,18 +2208,22 @@ exports[`Storyshots Changesets Replacements 1`] = `
<p
className="is-hidden-desktop"
/>
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
<div
className="ChangesetRow__FlexRow-tkpti5-7 fTLhSo"
>
changeset.contributors.authoredBy
<a
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
>
SCM Administrator
</a>
</p>
changeset.contributors.authoredBy
<a
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
>
SCM Administrator
</a>
</p>
</div>
</div>
</div>
</div>
@@ -2309,30 +2325,34 @@ exports[`Storyshots Changesets With Committer 1`] = `
<p
className="is-hidden-desktop"
/>
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
<div
className="ChangesetRow__FlexRow-tkpti5-7 fTLhSo"
>
changeset.contributors.authoredBy
<a
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
>
SCM Administrator
</a>
commaSeparatedList.lastDivider
changeset.contributors.committedBy
<a
href="mailto:zaphod.beeblebrox@hitchhiker.cm"
title="changeset.contributors.mailto zaphod.beeblebrox@hitchhiker.cm"
>
Zaphod Beeblebrox
</a>
</p>
changeset.contributors.authoredBy
<a
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
>
SCM Administrator
</a>
commaSeparatedList.lastDivider
changeset.contributors.committedBy
<a
href="mailto:zaphod.beeblebrox@hitchhiker.cm"
title="changeset.contributors.mailto zaphod.beeblebrox@hitchhiker.cm"
>
Zaphod Beeblebrox
</a>
</p>
</div>
</div>
</div>
</div>
@@ -2434,39 +2454,43 @@ exports[`Storyshots Changesets With Committer and Co-Author 1`] = `
<p
className="is-hidden-desktop"
/>
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
<div
className="ChangesetRow__FlexRow-tkpti5-7 fTLhSo"
>
changeset.contributors.authoredBy
<a
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
>
SCM Administrator
</a>
,
changeset.contributors.committedBy
<a
href="mailto:zaphod.beeblebrox@hitchhiker.cm"
title="changeset.contributors.mailto zaphod.beeblebrox@hitchhiker.cm"
>
Zaphod Beeblebrox
</a>
commaSeparatedList.lastDivider
changeset.contributors.coAuthoredBy
<a
href="mailto:ford.prefect@hitchhiker.com"
title="changeset.contributors.mailto ford.prefect@hitchhiker.com"
>
Ford Prefect
</a>
</p>
changeset.contributors.authoredBy
<a
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
>
SCM Administrator
</a>
,
changeset.contributors.committedBy
<a
href="mailto:zaphod.beeblebrox@hitchhiker.cm"
title="changeset.contributors.mailto zaphod.beeblebrox@hitchhiker.cm"
>
Zaphod Beeblebrox
</a>
commaSeparatedList.lastDivider
changeset.contributors.coAuthoredBy
<a
href="mailto:ford.prefect@hitchhiker.com"
title="changeset.contributors.mailto ford.prefect@hitchhiker.com"
>
Ford Prefect
</a>
</p>
</div>
</div>
</div>
</div>
@@ -2581,18 +2605,22 @@ exports[`Storyshots Changesets With avatar 1`] = `
<p
className="is-hidden-desktop"
/>
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
<div
className="ChangesetRow__FlexRow-tkpti5-7 fTLhSo"
>
changeset.contributors.authoredBy
<a
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
>
SCM Administrator
</a>
</p>
changeset.contributors.authoredBy
<a
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
>
SCM Administrator
</a>
</p>
</div>
</div>
</div>
</div>
@@ -2694,31 +2722,35 @@ exports[`Storyshots Changesets With multiple Co-Authors 1`] = `
<p
className="is-hidden-desktop"
/>
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
<div
className="ChangesetRow__FlexRow-tkpti5-7 fTLhSo"
>
changeset.contributors.authoredBy
<a
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
<p
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
>
SCM Administrator
</a>
commaSeparatedList.lastDivider
changeset.contributors.coAuthoredBy
<a
title="- Ford Prefect
changeset.contributors.authoredBy
<a
href="mailto:scm-admin@scm-manager.org"
title="changeset.contributors.mailto scm-admin@scm-manager.org"
>
SCM Administrator
</a>
commaSeparatedList.lastDivider
changeset.contributors.coAuthoredBy
<a
title="- Ford Prefect
- Zaphod Beeblebrox
- Tricia Marie McMillan"
>
changeset.contributors.more
</a>
</p>
>
changeset.contributors.more
</a>
</p>
</div>
</div>
</div>
</div>

View File

@@ -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<Props> {
createChangesetId = (changeset: Changeset) => {
const { repository } = this.props;
@@ -101,7 +108,7 @@ class ChangesetRow extends React.Component<Props> {
<AvatarWrapper>
<AvatarFigure className="media-left">
<FixedSizedAvatar className="image">
<AvatarImage person={changeset.author}/>
<AvatarImage person={changeset.author} />
</FixedSizedAvatar>
</AvatarFigure>
</AvatarWrapper>
@@ -119,24 +126,32 @@ class ChangesetRow extends React.Component<Props> {
</ExtensionPoint>
</h4>
<p className="is-hidden-touch">
<Trans i18nKey="repos:changeset.summary" components={[changesetId, dateFromNow]}/>
<Trans i18nKey="repos:changeset.summary" components={[changesetId, dateFromNow]} />
</p>
<p className="is-hidden-desktop">
<Trans i18nKey="repos:changeset.shortSummary" components={[changesetId, dateFromNow]}/>
<Trans i18nKey="repos:changeset.shortSummary" components={[changesetId, dateFromNow]} />
</p>
<AuthorWrapper className="is-size-7 is-ellipsis-overflow">
<ChangesetAuthor changeset={changeset}/>
</AuthorWrapper>
<FlexRow>
<AuthorWrapper className="is-size-7 is-ellipsis-overflow">
<ChangesetAuthor changeset={changeset} />
</AuthorWrapper>
{changeset?.signatures && changeset.signatures.length > 0 && (
<SignatureIcon
className="mx-2 pt-1"
signatures={changeset.signatures}
/>
)}
</FlexRow>
</Metadata>
</div>
</div>
<VCenteredColumn className="column">
<ChangesetTags changeset={changeset}/>
<ChangesetTags changeset={changeset} />
</VCenteredColumn>
</div>
</div>
<VCenteredChildColumn className={classNames("column", "is-flex")}>
<ChangesetButtonGroup repository={repository} changeset={changeset}/>
<ChangesetButtonGroup repository={repository} changeset={changeset} />
<ExtensionPoint
name="changeset.right"
props={{

View File

@@ -1,12 +1,12 @@
{
"name": "@scm-manager/ui-plugins",
"version": "2.3.0",
"version": "2.4.0-SNAPSHOT",
"license": "MIT",
"bin": {
"ui-plugins": "./bin/ui-plugins.js"
},
"dependencies": {
"@scm-manager/ui-components": "^2.3.0",
"@scm-manager/ui-components": "^2.4.0-SNAPSHOT",
"@scm-manager/ui-extensions": "^2.1.0",
"classnames": "^2.2.6",
"query-string": "^5.0.1",
@@ -25,7 +25,7 @@
"@scm-manager/tsconfig": "^2.1.0",
"@scm-manager/ui-scripts": "^2.1.0",
"@scm-manager/ui-tests": "^2.1.0",
"@scm-manager/ui-types": "^2.3.0",
"@scm-manager/ui-types": "^2.4.0-SNAPSHOT",
"@types/classnames": "^2.2.9",
"@types/enzyme": "^3.10.3",
"@types/fetch-mock": "^7.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@scm-manager/ui-styles",
"version": "2.1.0",
"version": "2.4.0-SNAPSHOT",
"description": "Styles for SCM-Manager",
"main": "src/scm.scss",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@scm-manager/ui-types",
"version": "2.3.0",
"version": "2.4.0-SNAPSHOT",
"description": "Flow types for SCM-Manager related Objects",
"main": "src/index.ts",
"files": [

View File

@@ -33,6 +33,7 @@ export type Changeset = Collection & {
author: Person;
description: string;
contributors?: Contributor[];
signatures?: Signature[];
_links: Links;
_embedded: {
tags?: Tag[];
@@ -41,6 +42,14 @@ export type Changeset = Collection & {
};
};
export type Signature = {
keyId: string;
type: string;
status: "VERIFIED" | "NOT_FOUND" | "INVALID";
owner: string;
contacts: string[];
}
export type Contributor = {
person: Person;
type: string;

View File

@@ -1,9 +1,9 @@
{
"name": "@scm-manager/ui-webapp",
"version": "2.3.0",
"version": "2.4.0-SNAPSHOT",
"private": true,
"dependencies": {
"@scm-manager/ui-components": "^2.3.0",
"@scm-manager/ui-components": "^2.4.0-SNAPSHOT",
"@scm-manager/ui-extensions": "^2.1.0",
"classnames": "^2.2.5",
"history": "^4.10.1",

View File

@@ -88,6 +88,12 @@
"shortSummary": "Committet <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}}"
},

View File

@@ -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}}"
},

View File

@@ -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 && (
<SignatureIcon className="mx-2" signatures={changeset.signatures} />
);
if (open) {
return (
<ContributorDetails>
<ContributorToggleLine onClick={e => setOpen(!open)}>
<Icon name="angle-down" /> {t("changeset.contributors.list")}
</ContributorToggleLine>
<FlexRow>
<ContributorToggleLine onClick={e => setOpen(!open)} className="is-ellipsis-overflow">
<Icon name="angle-down" /> {t("changeset.contributors.list")}
</ContributorToggleLine>
{signatureIcon}
</FlexRow>
<ContributorTable changeset={changeset} />
</ContributorDetails>
);
@@ -133,9 +150,10 @@ const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => {
return (
<>
<ContributorLine onClick={e => setOpen(!open)}>
<ContributorColumn>
<ContributorColumn className="is-ellipsis-overflow">
<Icon name="angle-right" /> <ChangesetAuthor changeset={changeset} />
</ContributorColumn>
{signatureIcon}
<CountColumn className={"is-hidden-mobile"}>
(
<span className="has-text-link">
@@ -190,9 +208,9 @@ class ChangesetDetails extends React.Component<Props, State> {
<AvatarImage person={changeset.author} />
</RightMarginP>
</AvatarWrapper>
<div className="media-content is-ellipsis-overflow">
<div className="media-content">
<Contributors changeset={changeset} />
<ChangesetSummary>
<ChangesetSummary className="is-ellipsis-overflow">
<p>
<Trans i18nKey="repos:changeset.summary" components={[id, date]} />
</p>

View File

@@ -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<Props> = ({ 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 (
<Tooltip location="top" message={createTooltipMessage()}>
<Icon name={"key"} className={className} color={getColor()} />
</Tooltip>
);
};
export default SignatureIcon;

View File

@@ -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<PublicKey> findPublicKey(String id) {
Optional<RawGpgKey> 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());
}

View File

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

View File

@@ -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<String> subs;
static String resolveIdFromKey(String rawKey) throws IOException, PGPException {
List<PGPPublicKey> 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<String> subs) {
this.master = master;
this.subs = subs;
}
static Keys resolve(String raw) {
return resolve(raw, Keys::collectKeys);
}
static Keys resolve(String raw, Function<String, List<PGPPublicKey>> parser) {
List<PGPPublicKey> parsedKeys = parser.apply(raw);
String master = null;
Set<String> 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<PGPPublicKey> collectKeys(String rawKey) throws IOException, PGPException {
List<PGPPublicKey> 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<PGPPublicKey> collectKeys(String rawKey) {
try {
List<PGPPublicKey> 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;
}
}

View File

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

View File

@@ -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<RawGpgKey> store;
private final DataStore<MasterKeyReference> 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<RawGpgKey> findById(String id) {
Optional<MasterKeyReference> reference = subKeyStore.getOptional(id);
if (reference.isPresent()) {
return store.getOptional(reference.get().getMasterKey());
}
return store.getOptional(id);
}

View File

@@ -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 <s.sdorra@gmail.com>",
"Sebastian Sdorra <sebastian.sdorra@cloudogu.com>");
}
@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<PublicKey> keys = gpg.findPublicKeysByUsername("trillian");

View File

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

View File

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

View File

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

View File

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

View File

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