From 819cded132b5d93d36716efbccc9c378ca2f2a7e Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Tue, 30 Jul 2024 17:10:15 +0200 Subject: [PATCH 01/10] Update channel schema to support signature verification --- .../java/org/wildfly/channel/Channel.java | 51 +++++- .../org/wildfly/channel/ChannelMapper.java | 5 +- .../org/wildfly/channel/v2.1.0/schema.json | 162 ++++++++++++++++++ .../channel/ChannelMapperTestCase.java | 44 ++++- .../channel/mapping/ChannelTestCase.java | 13 +- .../channels/channel-with-gpg-check.yaml | 18 ++ doc/spec.adoc | 31 +++- 7 files changed, 315 insertions(+), 9 deletions(-) create mode 100644 core/src/main/resources/org/wildfly/channel/v2.1.0/schema.json create mode 100644 core/src/test/resources/channels/channel-with-gpg-check.yaml diff --git a/core/src/main/java/org/wildfly/channel/Channel.java b/core/src/main/java/org/wildfly/channel/Channel.java index e3861c33..d31f22b6 100644 --- a/core/src/main/java/org/wildfly/channel/Channel.java +++ b/core/src/main/java/org/wildfly/channel/Channel.java @@ -47,6 +47,8 @@ public class Channel { private BlocklistCoordinate blocklistCoordinate; private ChannelManifestCoordinate manifestCoordinate; private NoStreamStrategy noStreamStrategy = NoStreamStrategy.NONE; + private Boolean gpgCheck; + private List gpgUrls; // no-arg constructor for maven plugins public Channel() { @@ -56,7 +58,7 @@ public Channel() { /** * Representation of a Channel resource using the current schema version. * - * @see #Channel(String, String, String, Vendor, List, ChannelManifestCoordinate, BlocklistCoordinate, NoStreamStrategy) + * @see #Channel(String, String, String, Vendor, List, ChannelManifestCoordinate, BlocklistCoordinate, NoStreamStrategy, Boolean, String) */ public Channel(String name, String description, @@ -72,7 +74,8 @@ public Channel(String name, repositories, manifestCoordinate, blocklistCoordinate, - noStreamStrategy); + noStreamStrategy, + null, null); } @JsonCreator @@ -84,7 +87,9 @@ public Channel(@JsonProperty(value = "schemaVersion", required = true) String sc @JsonInclude(NON_EMPTY) List repositories, @JsonProperty(value = "manifest") ChannelManifestCoordinate manifestCoordinate, @JsonProperty(value = "blocklist") @JsonInclude(NON_EMPTY) BlocklistCoordinate blocklistCoordinate, - @JsonProperty(value = "resolve-if-no-stream") NoStreamStrategy noStreamStrategy) { + @JsonProperty(value = "resolve-if-no-stream") NoStreamStrategy noStreamStrategy, + @JsonProperty(value = "gpg-check") Boolean gpgCheck, + @JsonProperty(value = "gpg-urls") List gpgUrls) { this.schemaVersion = schemaVersion; this.name = name; this.description = description; @@ -93,6 +98,8 @@ public Channel(@JsonProperty(value = "schemaVersion", required = true) String sc this.blocklistCoordinate = blocklistCoordinate; this.manifestCoordinate = manifestCoordinate; this.noStreamStrategy = (noStreamStrategy != null) ? noStreamStrategy: NoStreamStrategy.NONE; + this.gpgCheck = gpgCheck; + this.gpgUrls = (gpgUrls != null) ? gpgUrls : emptyList(); } public String getSchemaVersion() { @@ -137,6 +144,25 @@ public NoStreamStrategy getNoStreamStrategy() { return noStreamStrategy; } + // using a private method to return a Boolean for serializing + // this way channels without gpg-check field can be read/written without modifications + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonProperty("gpg-check") + private Boolean _isGpgCheck() { + return gpgCheck; + } + + @JsonIgnore + public boolean isGpgCheck() { + return gpgCheck!=null?gpgCheck:false; + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @JsonProperty("gpg-urls") + public List getGpgUrls() { + return gpgUrls; + } + /** * Strategies for resolving artifact versions if it is not listed in streams. *
    @@ -197,6 +223,8 @@ public static class Builder { private NoStreamStrategy strategy; private String description; private Vendor vendor; + private Boolean gpgCheck; + private List gpgUrls; public Builder() { } @@ -209,10 +237,12 @@ public Builder(Channel from) { this.strategy = from.getNoStreamStrategy(); this.description = from.getDescription(); this.vendor = from.getVendor(); + this.gpgCheck = from._isGpgCheck(); + this.gpgUrls = new ArrayList<>(from.getGpgUrls()); } public Channel build() { - return new Channel(name, description, vendor, repositories, manifestCoordinate, blocklistCoordinate, strategy); + return new Channel(ChannelMapper.CURRENT_SCHEMA_VERSION, name, description, vendor, repositories, manifestCoordinate, blocklistCoordinate, strategy, gpgCheck, gpgUrls); } public Builder setName(String name) { @@ -278,5 +308,18 @@ public Builder setResolveStrategy(NoStreamStrategy strategy) { this.strategy = strategy; return this; } + + public Builder setGpgCheck(boolean gpgCheck) { + this.gpgCheck = gpgCheck; + return this; + } + + public Builder addGpgUrl(String gpgUrl) { + if (this.gpgUrls == null) { + this.gpgUrls = new ArrayList<>(); + } + this.gpgUrls.add(gpgUrl); + return this; + } } } diff --git a/core/src/main/java/org/wildfly/channel/ChannelMapper.java b/core/src/main/java/org/wildfly/channel/ChannelMapper.java index e1d1f04c..30301c90 100644 --- a/core/src/main/java/org/wildfly/channel/ChannelMapper.java +++ b/core/src/main/java/org/wildfly/channel/ChannelMapper.java @@ -53,10 +53,12 @@ public class ChannelMapper { public static final String SCHEMA_VERSION_1_0_0 = "1.0.0"; public static final String SCHEMA_VERSION_2_0_0 = "2.0.0"; - public static final String CURRENT_SCHEMA_VERSION = SCHEMA_VERSION_2_0_0; + public static final String SCHEMA_VERSION_2_1_0 = "2.1.0"; + public static final String CURRENT_SCHEMA_VERSION = SCHEMA_VERSION_2_1_0; private static final String SCHEMA_1_0_0_FILE = "org/wildfly/channel/v1.0.0/schema.json"; private static final String SCHEMA_2_0_0_FILE = "org/wildfly/channel/v2.0.0/schema.json"; + private static final String SCHEMA_2_1_0_FILE = "org/wildfly/channel/v2.1.0/schema.json"; private static final YAMLFactory YAML_FACTORY = new YAMLFactory() .configure(YAMLGenerator.Feature.INDENT_ARRAYS_WITH_INDICATOR, true); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(YAML_FACTORY) @@ -71,6 +73,7 @@ public class ChannelMapper { } SCHEMAS.put(SCHEMA_VERSION_1_0_0, SCHEMA_FACTORY.getSchema(ChannelMapper.class.getClassLoader().getResourceAsStream(SCHEMA_1_0_0_FILE))); SCHEMAS.put(SCHEMA_VERSION_2_0_0, SCHEMA_FACTORY.getSchema(ChannelMapper.class.getClassLoader().getResourceAsStream(SCHEMA_2_0_0_FILE))); + SCHEMAS.put(SCHEMA_VERSION_2_1_0, SCHEMA_FACTORY.getSchema(ChannelMapper.class.getClassLoader().getResourceAsStream(SCHEMA_2_1_0_FILE))); } private static JsonSchema getSchema(JsonNode node) { diff --git a/core/src/main/resources/org/wildfly/channel/v2.1.0/schema.json b/core/src/main/resources/org/wildfly/channel/v2.1.0/schema.json new file mode 100644 index 00000000..06fb25ce --- /dev/null +++ b/core/src/main/resources/org/wildfly/channel/v2.1.0/schema.json @@ -0,0 +1,162 @@ +{ + "$id": "https://wildfly.org/channels/v2.1.0/schema.json", + "$schema": "https://json-schema.org/draft/2019-09/schema#", + "type": "object", + "required": ["schemaVersion", "repositories"], + "properties": { + "schemaVersion": { + "description": "The version of the schema defining a channel resource.", + "type": "string", + "pattern": "^[0-9]+.[0-9]+.[0-9]+$" + }, + "name": { + "description": "Name of the channel. This is a one-line human-readable description of the channel", + "type": "string" + }, + "description": { + "description": "Description of the channel. This is a multi-lines human-readable description of the channel", + "type": "string" + }, + "vendor": { + "description": "Vendor of the channel.", + "type": "object", + "properties": { + "name": { + "description": "Name of the vendor", + "type": "string" + }, + "support": { + "description": "Support level provided by the vendor", + "type": "string", + "enum": [ + "supported", + "tech-preview", + "community" + ] + } + }, + "required": ["name", "support"] + }, + "repositories": { + "description": "Repositories the channel uses to resolve its streams.", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "id": { + "description": "Id of the repository", + "type": "string" + }, + "url": { + "description": "URL of the repository", + "type": "string" + } + }, + "required": ["id", "url"] + } + }, + "manifest": { + "description": "Location of the channel's manifest", + "type": "object", + "properties": { + "maven": { + "type": "object", + "properties": { + "groupId": { + "description": "GroupID Maven coordinate of the manifest", + "type": "string" + }, + "artifactId": { + "description": "ArtifactID Maven coordinate of the manifest", + "type": "string" + }, + "version": { + "description": "Version Maven coordinate of the manifest", + "type": "string" + } + }, + "required": ["groupId", "artifactId"] + }, + "url": { + "description": "URL of the manifest file.", + "type": "string" + } + }, + "oneOf": [ + { + "required": [ + "maven" + ] + }, + { + "required": [ + "url" + ] + } + ] + }, + "blocklist": { + "description": "Location of the channel's blocklist", + "type": "object", + "properties": { + "maven": { + "type": "object", + "properties": { + "groupId": { + "description": "GroupID Maven coordinate of the blocklist", + "type": "string" + }, + "artifactId": { + "description": "ArtifactID Maven coordinate of the blocklist", + "type": "string" + }, + "version": { + "description": "Version Maven coordinate of the blocklist", + "type": "string" + } + }, + "required": ["groupId", "artifactId"] + }, + "url": { + "description": "URL of the blocklist file.", + "type": "string" + } + }, + "oneOf": [ + { + "required": [ + "maven" + ] + }, + { + "required": [ + "url" + ] + } + ] + }, + "resolve-if-no-stream": { + "description": "Strategy for resolving artifact versions if it is not listed in streams. If not specified, 'original' strategy is used.", + "type": "string", + "enum": [ + "latest", + "maven-latest", + "maven-release", + "none" + ] + }, + "gpg-check": { + "description": "Verify the signatures of artifacts provided from this channel.", + "type": "boolean" + }, + "gpg-urls": { + "description": "The URLs of the public keys used to sign channel artifacts.", + "type": "array", + "minItems": "1", + "items": { + "type": "string" + } + } + } +} diff --git a/core/src/test/java/org/wildfly/channel/ChannelMapperTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelMapperTestCase.java index 212d9d34..7ecf2dd3 100644 --- a/core/src/test/java/org/wildfly/channel/ChannelMapperTestCase.java +++ b/core/src/test/java/org/wildfly/channel/ChannelMapperTestCase.java @@ -16,14 +16,16 @@ */ package org.wildfly.channel; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; import java.net.URL; import java.util.List; +import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; public class ChannelMapperTestCase { @@ -106,4 +108,44 @@ public void writeChannelWithNoResolveStrategy() throws Exception { Channel readChannel = ChannelMapper.fromString(yaml).get(0); assertEquals(Channel.NoStreamStrategy.NONE, readChannel.getNoStreamStrategy()); } + + @Test + public void setGpgCheck() throws Exception { + verifyGpgCheck(false); + verifyGpgCheck(true); + } + + @Test + public void nullGpgCheckIsNotSerialized() throws Exception { + Channel.Builder channel = new Channel.Builder() + .addRepository("test", "https://test.org/repository"); + + final String yaml = ChannelMapper.toYaml(channel.build()); + assertThat(yaml) + .doesNotContain("gpg-check"); + } + + @Test + public void writeChannelWithGpgKeys() throws Exception { + Channel.Builder channel = new Channel.Builder() + .addRepository("test", "https://test.org/repository") + .addGpgUrl("https://gpg.test/key"); + + final String yaml = ChannelMapper.toYaml(channel.build()); + + Channel readChannel = ChannelMapper.fromString(yaml).get(0); + Assertions.assertThat(readChannel.getGpgUrls()) + .containsExactly("https://gpg.test/key"); + } + + private static void verifyGpgCheck(boolean value) throws IOException { + Channel.Builder channel = new Channel.Builder() + .addRepository("test", "https://test.org/repository") + .setGpgCheck(value); + + final String yaml = ChannelMapper.toYaml(channel.build()); + + Channel readChannel = ChannelMapper.fromString(yaml).get(0); + assertEquals(value, readChannel.isGpgCheck()); + } } diff --git a/core/src/test/java/org/wildfly/channel/mapping/ChannelTestCase.java b/core/src/test/java/org/wildfly/channel/mapping/ChannelTestCase.java index 938b03ee..3c21b78f 100644 --- a/core/src/test/java/org/wildfly/channel/mapping/ChannelTestCase.java +++ b/core/src/test/java/org/wildfly/channel/mapping/ChannelTestCase.java @@ -18,6 +18,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.io.InputStream; @@ -70,7 +71,7 @@ public void multipleChannelsTest() throws IOException { } @Test - public void simpleChannelTest() throws MalformedURLException { + public void simpleChannelTest() throws IOException { ClassLoader tccl = Thread.currentThread().getContextClassLoader(); URL file = tccl.getResource("channels/simple-channel.yaml"); @@ -99,4 +100,14 @@ public void channelWithBlocklist() throws MalformedURLException { assertEquals("org.wildfly", blocklist.getGroupId()); assertEquals("1.2.3", blocklist.getVersion()); } + + @Test + public void channelWithGpgCheck() throws IOException { + ClassLoader tccl = Thread.currentThread().getContextClassLoader(); + URL file = tccl.getResource("channels/channel-with-gpg-check.yaml"); + + Channel channel = ChannelMapper.from(file); + + assertTrue(channel.isGpgCheck()); + } } diff --git a/core/src/test/resources/channels/channel-with-gpg-check.yaml b/core/src/test/resources/channels/channel-with-gpg-check.yaml new file mode 100644 index 00000000..da55f7c0 --- /dev/null +++ b/core/src/test/resources/channels/channel-with-gpg-check.yaml @@ -0,0 +1,18 @@ +schemaVersion: "2.1.0" +name: My Channel +description: |- + This is my channel + with my stuff +vendor: + name: My Vendor + support: community +gpg-check: true +manifest: + maven: + groupId: org.wildfly + artifactId: test-manifest + versionPattern: ".*" +repositories: + - id: test + url: http://test.te + diff --git a/doc/spec.adoc b/doc/spec.adoc index 9ef8d093..44148f9d 100644 --- a/doc/spec.adoc +++ b/doc/spec.adoc @@ -5,7 +5,7 @@ [cols="1,1"] |=== -| Channel schema Version | 2.0.0 +| Channel schema Version | 2.1.0 | Manifest schema Version | 1.1.0 | Blocklist schema Version | 1.0.0 |=== @@ -76,6 +76,8 @@ A channel is composed of several fields: ** `maven-latest` - a version marked as `latest` in the Maven metadata ** `maven-release` - a version marked as `release` in the Maven metadata ** `none` - do not attempt to resolve versions of artifact not listed in the `streams` collection. Default value if no strategy is provided. +* Optional `gpg-check` boolean indicating if the artifacts resolved from this channel have to have a valid GPG signature. +* Optional `gpg-urls` a list of URLs that the public GPG keys used to validate artifact signatures are resolved from. ### Manifest definition A Channel Manifest is composed of following fields: @@ -234,12 +236,37 @@ A blocklist applies only to the channel that defined it, not its required channe During artifact version resolution, a stream matching artifact GA is located in the channel. The blocklist is always checked for excluded versions, except when using `resolveDirectMavenArtifact` method. The excluded versions are removed from the set of available artifact versions before the latest remaining version matching the stream’s pattern is used to resolve the artifact. If the blocklist excludes all available artifact versions, `UnresolvedMavenArtifactException` is thrown. +### Verifing artifact PGP signatures + +If a channel sets value of `gpg-check` property to `true`, any artifact resolved from it (including the manifest itself) has to have a valid GPG signature. + +#### Verifing maven artifact signatures + +Maven artifacts are expected to have their detached GPG signatures available in the channel repositories. The detached artifacts must have the same GAV as the artifact, but append ".asc" suffix to the file expension. The signature file must contain an armoured GPG signature. + +The signature file is resolved at the same time as the artifact. If the signature is invalid, the public key is not found, the public key is expired or revoked, the artifact must be rejected and a SignatureException must be thrown. + +#### Verifing manifest signatures + +If the manifest of a channel is defined as a Maven GA(V), it is treated as any other maven artifact. If it is defined as an URL, the signature file must be available at the same URL with ".asc" suffix. If the signature cannot be resolved, the channel creation must fail. + +#### Public keys + +The public keys are identified by the a keyID in hexadecimal form. The public keys required by the channel can be defined using `gpg-urls` property. Each `gpg-urls` must be a URL to a downloadable key. + +Implementations may provide additional means of providing keys - local stores, remote keyservers, etc. + ### Changelog +### Version 2.1.0 + +* Adding ability to verify artifact signatures +** Adding `gpg-check` and `gpg-urls` fields to the Channel definition. + ### Version 2.0.0 * Introduction of the Channel Manifest and Blocklist ### Version 1.0.0 -* Initial release of the Channel specification \ No newline at end of file +* Initial release of the Channel specification From 8543686cecc6c9797a91317288a7c39fb2878cc5 Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Tue, 30 Jul 2024 17:37:47 +0200 Subject: [PATCH 02/10] Add SignatureValidator API --- .../java/org/wildfly/channel/ChannelImpl.java | 58 ++++++++++++-- .../org/wildfly/channel/ChannelSession.java | 14 +++- .../channel/spi/MavenVersionsResolver.java | 3 +- .../wildfly/channel/spi/SignatureResult.java | 75 +++++++++++++++++++ .../channel/spi/SignatureValidator.java | 61 +++++++++++++++ .../channel/maven/VersionResolverFactory.java | 6 +- 6 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 core/src/main/java/org/wildfly/channel/spi/SignatureResult.java create mode 100644 core/src/main/java/org/wildfly/channel/spi/SignatureValidator.java diff --git a/core/src/main/java/org/wildfly/channel/ChannelImpl.java b/core/src/main/java/org/wildfly/channel/ChannelImpl.java index 85eb7027..9bbc8271 100644 --- a/core/src/main/java/org/wildfly/channel/ChannelImpl.java +++ b/core/src/main/java/org/wildfly/channel/ChannelImpl.java @@ -21,6 +21,7 @@ import static org.wildfly.channel.version.VersionMatcher.COMPARATOR; import java.io.File; +import java.io.IOException; import java.net.URL; import java.util.ArrayList; import java.util.Collections; @@ -34,6 +35,8 @@ import org.jboss.logging.Logger; import org.wildfly.channel.spi.MavenVersionsResolver; +import org.wildfly.channel.spi.SignatureResult; +import org.wildfly.channel.spi.SignatureValidator; import org.wildfly.channel.version.VersionMatcher; /** @@ -56,8 +59,11 @@ class ChannelImpl implements AutoCloseable { private boolean dependency = false; public Optional blocklist = Optional.empty(); + private ChannelManifestCoordinate resolvedCoordinate; + private SignatureValidator signatureValidator; + public ChannelManifest getManifest() { return channelManifest; } @@ -74,11 +80,12 @@ public ChannelImpl(Channel channelDefinition) { * @throws UnresolvedRequiredManifestException - if a required manifest cannot be resolved either via maven coordinates or in the list of channels * @throws CyclicDependencyException - if the required manifests form a cyclic dependency */ - void init(MavenVersionsResolver.Factory factory, List channels) { + void init(MavenVersionsResolver.Factory factory, List channels, SignatureValidator signatureValidator) { if (resolver != null) { //already initialized return; } + this.signatureValidator = signatureValidator; resolver = factory.create(channelDefinition.getRepositories()); @@ -118,7 +125,7 @@ private ChannelImpl findRequiredChannel(MavenVersionsResolver.Factory factory, L ChannelImpl foundChannel = null; for (ChannelImpl c: channels) { if (c.getManifest() == null) { - c.init(factory, channels); + c.init(factory, channels, signatureValidator); } if (manifestRequirement.getId().equals(c.getManifest().getId())) { foundChannel = c; @@ -149,11 +156,11 @@ private ChannelImpl createNewChannelFromMaven(MavenVersionsResolver.Factory fact version = latest.orElseThrow(() -> new RuntimeException(String.format("Can not determine the latest version for Maven artifact %s:%s:%s:%s", groupId, artifactId, ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER))); } - final ChannelImpl requiredChannel = new ChannelImpl(new Channel(null, null, null, channelDefinition.getRepositories(), + final ChannelImpl requiredChannel = new ChannelImpl(new Channel(ChannelMapper.CURRENT_SCHEMA_VERSION, null, null, null, channelDefinition.getRepositories(), new ChannelManifestCoordinate(groupId, artifactId, version), null, - Channel.NoStreamStrategy.NONE)); + Channel.NoStreamStrategy.NONE, channelDefinition.isGpgCheck(), channelDefinition.getGpgUrls())); try { - requiredChannel.init(factory, channels); + requiredChannel.init(factory, channels, signatureValidator); } catch (UnresolvedMavenArtifactException e) { throw new UnresolvedRequiredManifestException("Manifest with ID " + manifestRequirement.getId() + " is not available", manifestRequirement.getId(), e); } @@ -401,11 +408,50 @@ ResolveArtifactResult resolveArtifact(String groupId, String artifactId, String } } - return new ResolveArtifactResult(resolver.resolveArtifact(groupId, artifactId, extension, classifier, version), this); + final File artifact = resolver.resolveArtifact(groupId, artifactId, extension, classifier, version); + if (channelDefinition.isGpgCheck()) { + try { + final File signature = resolver.resolveArtifact(groupId, artifactId, extension + ".asc", classifier, version); + final MavenArtifact mavenArtifact = new MavenArtifact(groupId, artifactId, extension, classifier, version, artifact); + final SignatureResult signatureResult = signatureValidator.validateSignature(mavenArtifact, signature, channelDefinition.getGpgUrls()); + if (signatureResult.getResult() != SignatureResult.Result.OK) { + throw new SignatureValidator.SignatureException("Failed to verify an artifact signature", signatureResult); + } + } catch (ArtifactTransferException e) { + final ArtifactCoordinate missingSignatureCoords = e.getUnresolvedArtifacts().stream().findFirst().get(); + throw new SignatureValidator.SignatureException("Unable to find required signature for " + missingSignatureCoords, + SignatureResult.noSignature(missingSignatureCoords)); + } + } + return new ResolveArtifactResult(artifact, this); } List resolveArtifacts(List coordinates) throws UnresolvedMavenArtifactException { final List resolvedArtifacts = resolver.resolveArtifacts(coordinates); + + if (channelDefinition.isGpgCheck()) { + try { + final List signatures = resolver.resolveArtifacts(coordinates.stream() + .map(c->new ArtifactCoordinate(c.getGroupId(), c.getArtifactId(), c.getExtension() + ".asc", + c.getClassifier(), c.getVersion())) + .collect(Collectors.toList())); + for (int i = 0; i < resolvedArtifacts.size(); i++) { + final File artifact = resolvedArtifacts.get(i); + final ArtifactCoordinate c = coordinates.get(i); + final MavenArtifact mavenArtifact = new MavenArtifact(c.getGroupId(), c.getArtifactId(), + c.getExtension(), c.getClassifier(), c.getVersion(), artifact); + final File signature = signatures.get(i); + final SignatureResult signatureResult = signatureValidator.validateSignature(mavenArtifact, signature, channelDefinition.getGpgUrls()); + if (signatureResult.getResult() != SignatureResult.Result.OK) { + throw new SignatureValidator.SignatureException("Failed to verify an artifact signature", signatureResult); + } + } + } catch (ArtifactTransferException e) { + final ArtifactCoordinate artifact = e.getUnresolvedArtifacts().stream().findFirst().get(); + throw new SignatureValidator.SignatureException(String.format("Unable to find required signature for %s:%s:%s",artifact.getGroupId(), artifact.getArtifactId(), artifact.getVersion()), SignatureResult.noSignature(artifact)); + } + } + return resolvedArtifacts.stream().map(f->new ResolveArtifactResult(f, this)).collect(Collectors.toList()); } diff --git a/core/src/main/java/org/wildfly/channel/ChannelSession.java b/core/src/main/java/org/wildfly/channel/ChannelSession.java index 3a03ff8a..3d006527 100644 --- a/core/src/main/java/org/wildfly/channel/ChannelSession.java +++ b/core/src/main/java/org/wildfly/channel/ChannelSession.java @@ -35,6 +35,7 @@ import org.jboss.logging.Logger; import org.wildfly.channel.spi.MavenVersionsResolver; +import org.wildfly.channel.spi.SignatureValidator; import org.wildfly.channel.version.VersionMatcher; /** @@ -60,19 +61,24 @@ public class ChannelSession implements AutoCloseable { * @throws CyclicDependencyException - if the required manifests form a cyclic dependency */ public ChannelSession(List channelDefinitions, MavenVersionsResolver.Factory factory) { - this(channelDefinitions, factory, DEFAULT_SPLIT_ARTIFACT_PARALLELISM); + this(channelDefinitions, factory, DEFAULT_SPLIT_ARTIFACT_PARALLELISM, SignatureValidator.REJECTING_VALIDATOR); + } + + public ChannelSession(List channelDefinitions, MavenVersionsResolver.Factory factory, SignatureValidator signatureValidator) { + this(channelDefinitions, factory, DEFAULT_SPLIT_ARTIFACT_PARALLELISM, signatureValidator); } /** * Create a ChannelSession. * - * @param channels the list of channels to resolve Maven artifact + * @param channelDefinitions the list of channels to resolve Maven artifact * @param factory Factory to create {@code MavenVersionsResolver} that are performing the actual Maven resolution. * @param versionResolutionParallelism Number of threads to use when resolving available artifact versions. * @throws UnresolvedRequiredManifestException - if a required manifest cannot be resolved either via maven coordinates or in the list of channels * @throws CyclicDependencyException - if the required manifests form a cyclic dependency */ - public ChannelSession(List channelDefinitions, MavenVersionsResolver.Factory factory, int versionResolutionParallelism) { + public ChannelSession(List channelDefinitions, MavenVersionsResolver.Factory factory, + int versionResolutionParallelism, SignatureValidator signatureValidator) { requireNonNull(channelDefinitions); requireNonNull(factory); @@ -81,7 +87,7 @@ public ChannelSession(List channelDefinitions, MavenVersionsResolver.Fa List channelList = channelDefinitions.stream().map(ChannelImpl::new).collect(Collectors.toList()); for (ChannelImpl channel : channelList) { - channel.init(factory, channelList); + channel.init(factory, channelList, signatureValidator); } // filter out channels marked as dependency, so that resolution starts only at top level channels this.channels = channelList.stream().filter(c->!c.isDependency()).collect(Collectors.toList()); diff --git a/core/src/main/java/org/wildfly/channel/spi/MavenVersionsResolver.java b/core/src/main/java/org/wildfly/channel/spi/MavenVersionsResolver.java index b82889b7..f8dbb61b 100644 --- a/core/src/main/java/org/wildfly/channel/spi/MavenVersionsResolver.java +++ b/core/src/main/java/org/wildfly/channel/spi/MavenVersionsResolver.java @@ -60,7 +60,8 @@ public interface MavenVersionsResolver extends Closeable { * * @throws ArtifactTransferException if the artifact can not be resolved. */ - File resolveArtifact(String groupId, String artifactId, String extension, String classifier, String version) throws ArtifactTransferException; + File resolveArtifact(String groupId, String artifactId, String extension, String classifier, String version) + throws ArtifactTransferException; /** * Resolve a list of maven artifacts based on the full coordinates. diff --git a/core/src/main/java/org/wildfly/channel/spi/SignatureResult.java b/core/src/main/java/org/wildfly/channel/spi/SignatureResult.java new file mode 100644 index 00000000..dfdca40c --- /dev/null +++ b/core/src/main/java/org/wildfly/channel/spi/SignatureResult.java @@ -0,0 +1,75 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.wildfly.channel.spi; + +import org.wildfly.channel.ArtifactCoordinate; + +public class SignatureResult { + + private ArtifactCoordinate coord; + private String keyId; + private String message; + + public static SignatureResult noMatchingCertificate(ArtifactCoordinate coord, String keyID) { + return new SignatureResult(Result.NO_MATCHING_CERT, coord, keyID, null); + } + + public static SignatureResult revoked(ArtifactCoordinate artifactCoordinate, String keyID, String revocationReason) { + return new SignatureResult(Result.REVOKED, artifactCoordinate, keyID, revocationReason); + } + + public static SignatureResult expired(ArtifactCoordinate artifactCoordinate, String keyID) { + return new SignatureResult(Result.EXPIRED, artifactCoordinate, keyID, null); + } + + public static SignatureResult noSignature(ArtifactCoordinate artifactCoordinate) { + return new SignatureResult(Result.NO_SIGNATURE, artifactCoordinate, null, null); + } + + public static SignatureResult invalid(ArtifactCoordinate artifactCoordinate) { + return new SignatureResult(Result.INVALID, artifactCoordinate, null, null); + } + + public enum Result {OK, NO_MATCHING_CERT, REVOKED, EXPIRED, NO_SIGNATURE, INVALID;} + private final Result result; + public static SignatureResult ok() { + return new SignatureResult(Result.OK, null, null, null); + } + + private SignatureResult(Result result, ArtifactCoordinate coord, String keyID, String message) { + this.result = result; + this.coord = coord; + this.keyId = keyID; + this.message = message; + } + + public Result getResult() { + return result; + } + + public ArtifactCoordinate getArtifact() { + return coord; + } + + public String getKeyId() { + return keyId; + } + + public String getMessage() { + return message; + } +} diff --git a/core/src/main/java/org/wildfly/channel/spi/SignatureValidator.java b/core/src/main/java/org/wildfly/channel/spi/SignatureValidator.java new file mode 100644 index 00000000..6eb00890 --- /dev/null +++ b/core/src/main/java/org/wildfly/channel/spi/SignatureValidator.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.wildfly.channel.spi; + +import org.wildfly.channel.MavenArtifact; + +import java.io.File; +import java.io.IOException; +import java.util.List; + +/** + * Called to validate detached signatures of artifacts resolved in the channel + */ +public interface SignatureValidator { + SignatureValidator REJECTING_VALIDATOR = (artifact, signature, gpgUrl) -> { + throw new SignatureException("Not implemented", SignatureResult.noSignature(artifact)); + }; + + /** + * validates a signature of {@code artifact}. The locally downloaded {@code signature} has to be an armour encoded GPG signature. + * + * @param artifact - {@code MavenArtifact} to validate. Includes a full GAV and the local artifact file. + * @param signature - local file containing armour encoded detached GPG signature for the {@code artifact}. + * @param gpgUrls - URLs of the keys defined in the channel. Empty collection if channel does not define any signatures. + * @return {@link SignatureResult} with the result of validation + * @throws SignatureException - if an unexpected error occurred when handling the keys. + */ + SignatureResult validateSignature(MavenArtifact artifact, File signature, List gpgUrls) throws SignatureException; + + class SignatureException extends RuntimeException { + private final SignatureResult signatureResult; + + public SignatureException(String message, Throwable cause, SignatureResult signatureResult) { + super(message, cause); + this.signatureResult = signatureResult; + } + + public SignatureException(String message, SignatureResult signatureResult) { + super(message); + this.signatureResult = signatureResult; + } + + public SignatureResult getSignatureResult() { + return signatureResult; + } + } +} diff --git a/maven-resolver/src/main/java/org/wildfly/channel/maven/VersionResolverFactory.java b/maven-resolver/src/main/java/org/wildfly/channel/maven/VersionResolverFactory.java index 88555df0..2861e0f3 100644 --- a/maven-resolver/src/main/java/org/wildfly/channel/maven/VersionResolverFactory.java +++ b/maven-resolver/src/main/java/org/wildfly/channel/maven/VersionResolverFactory.java @@ -60,10 +60,12 @@ import org.wildfly.channel.Channel; import org.wildfly.channel.ChannelMapper; import org.wildfly.channel.ChannelMetadataCoordinate; +import org.wildfly.channel.MavenArtifact; import org.wildfly.channel.Repository; import org.wildfly.channel.NoStreamFoundException; import org.wildfly.channel.UnresolvedMavenArtifactException; import org.wildfly.channel.spi.MavenVersionsResolver; +import org.wildfly.channel.spi.SignatureValidator; import org.wildfly.channel.version.VersionMatcher; import org.jboss.logging.Logger; @@ -96,6 +98,7 @@ public VersionResolverFactory(RepositorySystem system, RepositorySystemSession session) { this(system, session, DEFAULT_REPOSITORY_MAPPER); } + public VersionResolverFactory(RepositorySystem system, RepositorySystemSession session, Function repositoryFactory) { @@ -173,7 +176,8 @@ public Set getAllVersions(String groupId, String artifactId, String exte } @Override - public File resolveArtifact(String groupId, String artifactId, String extension, String classifier, String version) throws ArtifactTransferException { + public File resolveArtifact(String groupId, String artifactId, String extension, String classifier, String version) + throws ArtifactTransferException, SignatureValidator.SignatureException { requireNonNull(groupId); requireNonNull(artifactId); requireNonNull(version); From 20ecc546aa872f484e47a66f5cfaf63821f85bc6 Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Thu, 1 Aug 2024 09:40:51 +0200 Subject: [PATCH 03/10] Add validation of URL manifests --- .../java/org/wildfly/channel/ChannelImpl.java | 153 +++++++++++++++--- .../InvalidChannelMetadataException.java | 5 + .../channel/spi/MavenVersionsResolver.java | 1 + .../wildfly/channel/spi/SignatureResult.java | 32 ++-- .../channel/spi/SignatureValidator.java | 11 +- .../channel/spi/ValidationResource.java | 55 +++++++ .../channel/ChannelSessionInitTestCase.java | 22 +-- .../channel/ChannelSessionTestCase.java | 4 +- .../channel/ChannelWithBlocklistTestCase.java | 81 +++++----- .../ChannelWithRequirementsTestCase.java | 43 +++-- 10 files changed, 293 insertions(+), 114 deletions(-) create mode 100644 core/src/main/java/org/wildfly/channel/spi/ValidationResource.java diff --git a/core/src/main/java/org/wildfly/channel/ChannelImpl.java b/core/src/main/java/org/wildfly/channel/ChannelImpl.java index 9bbc8271..c8d9bd53 100644 --- a/core/src/main/java/org/wildfly/channel/ChannelImpl.java +++ b/core/src/main/java/org/wildfly/channel/ChannelImpl.java @@ -21,7 +21,10 @@ import static org.wildfly.channel.version.VersionMatcher.COMPARATOR; import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.IOException; +import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.Collections; @@ -37,6 +40,7 @@ import org.wildfly.channel.spi.MavenVersionsResolver; import org.wildfly.channel.spi.SignatureResult; import org.wildfly.channel.spi.SignatureValidator; +import org.wildfly.channel.spi.ValidationResource; import org.wildfly.channel.version.VersionMatcher; /** @@ -111,7 +115,7 @@ void init(MavenVersionsResolver.Factory factory, List channels, Sig BlocklistCoordinate blocklistCoordinate = resolveBlocklistVersion(channelDefinition); if (blocklistCoordinate != null) { resolvedChannelBuilder.setBlocklistCoordinate(blocklistCoordinate); - final List urls = resolver.resolveChannelMetadata(List.of(blocklistCoordinate)); + final List urls = resolveChannelMetadata(List.of(blocklistCoordinate), true); this.blocklist = urls.stream() .map(Blocklist::from) .findFirst(); @@ -216,10 +220,6 @@ static class ResolveLatestVersionResult { } } - private Set attemptedRepositories() { - return new HashSet<>(channelDefinition.getRepositories()); - } - private ChannelManifestCoordinate resolveManifestVersion(Channel baseDefinition) { final ChannelManifestCoordinate manifestCoordinate = baseDefinition.getManifestCoordinate(); @@ -288,12 +288,86 @@ private ChannelManifest resolveManifest(ChannelManifestCoordinate manifestCoordi } else { resolvedCoordinate = manifestCoordinate; } - return resolver.resolveChannelMetadata(List.of(resolvedCoordinate)) + return resolveChannelMetadata(List.of(resolvedCoordinate), false) .stream() .map(ChannelManifestMapper::from) .findFirst().orElseThrow(); } + /** + * Resolve a list of channel metadata artifacts based on the coordinates. + * If the {@code ChannelMetadataCoordinate} contains non-null URL, that URL is returned. + * If the {@code ChannelMetadataCoordinate} contains non-null Maven coordinates, the Maven artifact will be resolved + * and a URL to it will be returned. + * If the Maven coordinates specify only groupId and artifactId, latest available version of matching Maven artifact + * will be resolved. + * + * The order of returned URLs is the same as order of coordinates. + * + * @param coords - list of ChannelMetadataCoordinate. + * @param optional - if artifact is optional, the method will return an empty collection if no versions are found + * + * @return a list of URLs to the metadata files + * + * @throws ArtifactTransferException if any artifacts can not be resolved. + */ + public List resolveChannelMetadata(List coords, boolean optional) throws ArtifactTransferException { + requireNonNull(coords); + + List channels = new ArrayList<>(); + + for (ChannelMetadataCoordinate coord : coords) { + if (coord.getUrl() != null) { + LOG.infof("Resolving channel metadata at %s", coord.getUrl()); + channels.add(coord.getUrl()); + if (channelDefinition.isGpgCheck()) { + try { + validateGpgSignature(coord.getUrl(), new URL(coord.getUrl().toExternalForm()+".asc")); + } catch (IOException e) { + throw new InvalidChannelMetadataException("Unable to download a detached signature file from: " + coord.getUrl().toExternalForm()+".asc", + List.of(e.getMessage()), e); + } + } + continue; + } + + String version = coord.getVersion(); + if (version == null) { + Set versions = resolver.getAllVersions(coord.getGroupId(), coord.getArtifactId(), coord.getExtension(), coord.getClassifier()); + Optional latestVersion = VersionMatcher.getLatestVersion(versions); + if (latestVersion.isPresent()){ + version = latestVersion.get(); + } else if (optional) { + return Collections.emptyList(); + } else { + throw new ArtifactTransferException(String.format("Unable to resolve the latest version of channel metadata %s:%s", coord.getGroupId(), coord.getArtifactId()), + singleton(new ArtifactCoordinate(coord.getGroupId(), coord.getArtifactId(), coord.getExtension(), coord.getClassifier(), "")), + attemptedRepositories()); + } + } + LOG.infof("Resolving channel metadata from Maven artifact %s:%s:%s", coord.getGroupId(), coord.getArtifactId(), version); + File channelArtifact = resolver.resolveArtifact(coord.getGroupId(), coord.getArtifactId(), coord.getExtension(), coord.getClassifier(), version); + try { + channels.add(channelArtifact.toURI().toURL()); + if (channelDefinition.isGpgCheck()) { + validateGpgSignature(coord.getGroupId(), coord.getArtifactId(), coord.getExtension(), coord.getClassifier(), version, channelArtifact); + } + } catch (MalformedURLException e) { + throw new ArtifactTransferException(String.format("Unable to resolve the latest version of channel metadata %s:%s", coord.getGroupId(), coord.getArtifactId()), e, + singleton(new ArtifactCoordinate(coord.getGroupId(), coord.getArtifactId(), + coord.getExtension(), coord.getClassifier(), coord.getVersion())), + attemptedRepositories()); + } + } + return channels; + } + + private Set attemptedRepositories() { + return channelDefinition.getRepositories().stream() + .map(r -> new Repository(r.getId(), r.getUrl())) + .collect(Collectors.toSet()); + } + Optional resolveLatestVersion(String groupId, String artifactId, String extension, String classifier, String baseVersion) { requireNonNull(groupId); requireNonNull(artifactId); @@ -410,22 +484,42 @@ ResolveArtifactResult resolveArtifact(String groupId, String artifactId, String final File artifact = resolver.resolveArtifact(groupId, artifactId, extension, classifier, version); if (channelDefinition.isGpgCheck()) { - try { - final File signature = resolver.resolveArtifact(groupId, artifactId, extension + ".asc", classifier, version); - final MavenArtifact mavenArtifact = new MavenArtifact(groupId, artifactId, extension, classifier, version, artifact); - final SignatureResult signatureResult = signatureValidator.validateSignature(mavenArtifact, signature, channelDefinition.getGpgUrls()); - if (signatureResult.getResult() != SignatureResult.Result.OK) { - throw new SignatureValidator.SignatureException("Failed to verify an artifact signature", signatureResult); - } - } catch (ArtifactTransferException e) { - final ArtifactCoordinate missingSignatureCoords = e.getUnresolvedArtifacts().stream().findFirst().get(); - throw new SignatureValidator.SignatureException("Unable to find required signature for " + missingSignatureCoords, - SignatureResult.noSignature(missingSignatureCoords)); - } + validateGpgSignature(groupId, artifactId, extension, classifier, version, artifact); } return new ResolveArtifactResult(artifact, this); } + private void validateGpgSignature(String groupId, String artifactId, String extension, String classifier, + String version, File artifact) { + final ValidationResource mavenArtifact = new ValidationResource.MavenResource(groupId, artifactId, extension, + classifier, version); + try { + final File signature = resolver.resolveArtifact(groupId, artifactId, extension + ".asc", + classifier, version); + final SignatureResult signatureResult = signatureValidator.validateSignature( + mavenArtifact, new FileInputStream(artifact), new FileInputStream(signature), + channelDefinition.getGpgUrls()); + if (signatureResult.getResult() != SignatureResult.Result.OK) { + throw new SignatureValidator.SignatureException("Failed to verify an artifact signature", signatureResult); + } + } catch (ArtifactTransferException | FileNotFoundException e) { + throw new SignatureValidator.SignatureException("Unable to find required signature for " + mavenArtifact, + SignatureResult.noSignature(mavenArtifact)); + } + } + + private void validateGpgSignature(URL artifactFile, URL signature) throws IOException { + final SignatureResult signatureResult = signatureValidator.validateSignature( + new ValidationResource.UrlResource(artifactFile), + artifactFile.openStream(), signature.openStream(), + channelDefinition.getGpgUrls() + ); + + if (signatureResult.getResult() != SignatureResult.Result.OK) { + throw new SignatureValidator.SignatureException("Failed to verify an artifact signature", signatureResult); + } + } + List resolveArtifacts(List coordinates) throws UnresolvedMavenArtifactException { final List resolvedArtifacts = resolver.resolveArtifacts(coordinates); @@ -438,17 +532,26 @@ List resolveArtifacts(List coordinate for (int i = 0; i < resolvedArtifacts.size(); i++) { final File artifact = resolvedArtifacts.get(i); final ArtifactCoordinate c = coordinates.get(i); - final MavenArtifact mavenArtifact = new MavenArtifact(c.getGroupId(), c.getArtifactId(), - c.getExtension(), c.getClassifier(), c.getVersion(), artifact); + final ValidationResource.MavenResource mavenArtifact = new ValidationResource.MavenResource(c.getGroupId(), c.getArtifactId(), + c.getExtension(), c.getClassifier(), c.getVersion()); final File signature = signatures.get(i); - final SignatureResult signatureResult = signatureValidator.validateSignature(mavenArtifact, signature, channelDefinition.getGpgUrls()); - if (signatureResult.getResult() != SignatureResult.Result.OK) { - throw new SignatureValidator.SignatureException("Failed to verify an artifact signature", signatureResult); + try { + final SignatureResult signatureResult = signatureValidator.validateSignature(mavenArtifact, + new FileInputStream(artifact), new FileInputStream(signature), channelDefinition.getGpgUrls()); + if (signatureResult.getResult() != SignatureResult.Result.OK) { + throw new SignatureValidator.SignatureException("Failed to verify an artifact signature", signatureResult); + } + } catch (FileNotFoundException e) { + throw new SignatureValidator.SignatureException(String.format("Unable to find required signature for %s:%s:%s", + mavenArtifact.getGroupId(), mavenArtifact.getArtifactId(), mavenArtifact.getVersion()), + SignatureResult.noSignature(mavenArtifact)); } } } catch (ArtifactTransferException e) { - final ArtifactCoordinate artifact = e.getUnresolvedArtifacts().stream().findFirst().get(); - throw new SignatureValidator.SignatureException(String.format("Unable to find required signature for %s:%s:%s",artifact.getGroupId(), artifact.getArtifactId(), artifact.getVersion()), SignatureResult.noSignature(artifact)); + final ValidationResource.MavenResource artifact = new ValidationResource.MavenResource(e.getUnresolvedArtifacts().stream().findFirst().get()); + throw new SignatureValidator.SignatureException(String.format("Unable to find required signature for %s:%s:%s", + artifact.getGroupId(), artifact.getArtifactId(), artifact.getVersion()), + SignatureResult.noSignature(artifact)); } } diff --git a/core/src/main/java/org/wildfly/channel/InvalidChannelMetadataException.java b/core/src/main/java/org/wildfly/channel/InvalidChannelMetadataException.java index 5d2208de..1e7b027a 100644 --- a/core/src/main/java/org/wildfly/channel/InvalidChannelMetadataException.java +++ b/core/src/main/java/org/wildfly/channel/InvalidChannelMetadataException.java @@ -26,6 +26,11 @@ public InvalidChannelMetadataException(String message, List messages) { this.messages = messages; } + public InvalidChannelMetadataException(String message, List messages, Exception cause) { + super(message, cause); + this.messages = messages; + } + public List getValidationMessages() { return messages; } diff --git a/core/src/main/java/org/wildfly/channel/spi/MavenVersionsResolver.java b/core/src/main/java/org/wildfly/channel/spi/MavenVersionsResolver.java index f8dbb61b..59c7685f 100644 --- a/core/src/main/java/org/wildfly/channel/spi/MavenVersionsResolver.java +++ b/core/src/main/java/org/wildfly/channel/spi/MavenVersionsResolver.java @@ -92,6 +92,7 @@ File resolveArtifact(String groupId, String artifactId, String extension, String * * @throws ArtifactTransferException if any artifacts can not be resolved. */ + @Deprecated List resolveChannelMetadata(List manifestCoords) throws ArtifactTransferException; /** diff --git a/core/src/main/java/org/wildfly/channel/spi/SignatureResult.java b/core/src/main/java/org/wildfly/channel/spi/SignatureResult.java index dfdca40c..4619c1c6 100644 --- a/core/src/main/java/org/wildfly/channel/spi/SignatureResult.java +++ b/core/src/main/java/org/wildfly/channel/spi/SignatureResult.java @@ -16,32 +16,30 @@ */ package org.wildfly.channel.spi; -import org.wildfly.channel.ArtifactCoordinate; - public class SignatureResult { - private ArtifactCoordinate coord; + private ValidationResource resource; private String keyId; private String message; - public static SignatureResult noMatchingCertificate(ArtifactCoordinate coord, String keyID) { - return new SignatureResult(Result.NO_MATCHING_CERT, coord, keyID, null); + public static SignatureResult noMatchingCertificate(ValidationResource resource, String keyID) { + return new SignatureResult(Result.NO_MATCHING_CERT, resource, keyID, null); } - public static SignatureResult revoked(ArtifactCoordinate artifactCoordinate, String keyID, String revocationReason) { - return new SignatureResult(Result.REVOKED, artifactCoordinate, keyID, revocationReason); + public static SignatureResult revoked(ValidationResource resource, String keyID, String revocationReason) { + return new SignatureResult(Result.REVOKED, resource, keyID, revocationReason); } - public static SignatureResult expired(ArtifactCoordinate artifactCoordinate, String keyID) { - return new SignatureResult(Result.EXPIRED, artifactCoordinate, keyID, null); + public static SignatureResult expired(ValidationResource resource, String keyID) { + return new SignatureResult(Result.EXPIRED, resource, keyID, null); } - public static SignatureResult noSignature(ArtifactCoordinate artifactCoordinate) { - return new SignatureResult(Result.NO_SIGNATURE, artifactCoordinate, null, null); + public static SignatureResult noSignature(ValidationResource resource) { + return new SignatureResult(Result.NO_SIGNATURE, resource, null, null); } - public static SignatureResult invalid(ArtifactCoordinate artifactCoordinate) { - return new SignatureResult(Result.INVALID, artifactCoordinate, null, null); + public static SignatureResult invalid(ValidationResource resource) { + return new SignatureResult(Result.INVALID, resource, null, null); } public enum Result {OK, NO_MATCHING_CERT, REVOKED, EXPIRED, NO_SIGNATURE, INVALID;} @@ -50,9 +48,9 @@ public static SignatureResult ok() { return new SignatureResult(Result.OK, null, null, null); } - private SignatureResult(Result result, ArtifactCoordinate coord, String keyID, String message) { + private SignatureResult(Result result, ValidationResource resource, String keyID, String message) { this.result = result; - this.coord = coord; + this.resource = resource; this.keyId = keyID; this.message = message; } @@ -61,8 +59,8 @@ public Result getResult() { return result; } - public ArtifactCoordinate getArtifact() { - return coord; + public ValidationResource getResource() { + return resource; } public String getKeyId() { diff --git a/core/src/main/java/org/wildfly/channel/spi/SignatureValidator.java b/core/src/main/java/org/wildfly/channel/spi/SignatureValidator.java index 6eb00890..adc42bdb 100644 --- a/core/src/main/java/org/wildfly/channel/spi/SignatureValidator.java +++ b/core/src/main/java/org/wildfly/channel/spi/SignatureValidator.java @@ -16,18 +16,15 @@ */ package org.wildfly.channel.spi; -import org.wildfly.channel.MavenArtifact; - -import java.io.File; -import java.io.IOException; +import java.io.InputStream; import java.util.List; /** * Called to validate detached signatures of artifacts resolved in the channel */ public interface SignatureValidator { - SignatureValidator REJECTING_VALIDATOR = (artifact, signature, gpgUrl) -> { - throw new SignatureException("Not implemented", SignatureResult.noSignature(artifact)); + SignatureValidator REJECTING_VALIDATOR = (artifactSource, artifactStream, signatureStream, gpgUrls) -> { + throw new SignatureException("Not implemented", SignatureResult.noSignature(artifactSource)); }; /** @@ -39,7 +36,7 @@ public interface SignatureValidator { * @return {@link SignatureResult} with the result of validation * @throws SignatureException - if an unexpected error occurred when handling the keys. */ - SignatureResult validateSignature(MavenArtifact artifact, File signature, List gpgUrls) throws SignatureException; + SignatureResult validateSignature(ValidationResource artifactSource, InputStream artifactStream, InputStream signatureStream, List gpgUrls) throws SignatureException; class SignatureException extends RuntimeException { private final SignatureResult signatureResult; diff --git a/core/src/main/java/org/wildfly/channel/spi/ValidationResource.java b/core/src/main/java/org/wildfly/channel/spi/ValidationResource.java new file mode 100644 index 00000000..8cd7164d --- /dev/null +++ b/core/src/main/java/org/wildfly/channel/spi/ValidationResource.java @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.wildfly.channel.spi; + +import java.net.URL; + +import org.wildfly.channel.ArtifactCoordinate; + +public interface ValidationResource { + + class UrlResource implements ValidationResource { + private URL resourceUrl; + + public UrlResource(URL resourceUrl) { + this.resourceUrl = resourceUrl; + } + + public URL getResourceUrl() { + return resourceUrl; + } + } + + class MavenResource extends ArtifactCoordinate implements ValidationResource { + + public MavenResource(String groupId, String artifactId, String extension, String classifier, String version) { + super(groupId, artifactId, extension, classifier, version); + } + + public MavenResource(ArtifactCoordinate artifactCoordinate) { + super(artifactCoordinate.getGroupId(), + artifactCoordinate.getArtifactId(), + artifactCoordinate.getExtension(), + artifactCoordinate.getClassifier(), + artifactCoordinate.getVersion()); + } + } + + default boolean isMavenResource() { + return this instanceof MavenResource; + } +} diff --git a/core/src/test/java/org/wildfly/channel/ChannelSessionInitTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelSessionInitTestCase.java index 3f1a8d9a..10514e03 100644 --- a/core/src/test/java/org/wildfly/channel/ChannelSessionInitTestCase.java +++ b/core/src/test/java/org/wildfly/channel/ChannelSessionInitTestCase.java @@ -24,7 +24,6 @@ import java.io.File; import java.io.IOException; -import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -123,7 +122,8 @@ public void throwExceptionRequiredChannelIdNotAvailableAndNotAbleToResolve() thr .build(); mockManifest(resolver, baseManifest, "test.channels:base-manifest:1.0.0"); - when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test.channels", "i-dont-exist", "1.0.0")))) + when(resolver.resolveArtifact("test.channels", "i-dont-exist", ChannelManifest.EXTENSION, + ChannelManifest.CLASSIFIER, "1.0.0")) .thenThrow(ArtifactTransferException.class); List channels = List.of(new Channel.Builder() @@ -401,6 +401,10 @@ public void getVersionOfResolvedBlocklist() throws Exception { mockManifest(resolver, requiredManifest, "test.channels:base-manifest:1.0.0"); when(resolver.getAllVersions("test.channels", "blocklist", BlocklistCoordinate.EXTENSION, BlocklistCoordinate.CLASSIFIER)) .thenReturn(Set.of("1.0.0")); + final File testFile = tempDir.resolve("test.yaml").toFile(); + Files.writeString(testFile.toPath(), "schemaVersion: 1.0.0"); + when(resolver.resolveArtifact("test.channels", "blocklist", BlocklistCoordinate.EXTENSION, BlocklistCoordinate.CLASSIFIER, "1.0.0")) + .thenReturn(testFile); final List channels = List.of(new Channel.Builder() .setName("channel one") @@ -422,17 +426,17 @@ private void mockManifest(MavenVersionsResolver resolver, ChannelManifest manife mockManifest(resolver, ChannelManifestMapper.toYaml(manifest), gav); } - private void mockManifest(MavenVersionsResolver resolver, String manifest, String gav) throws IOException { + private void mockManifest(MavenVersionsResolver resolver, String manifest, String gavString) throws IOException { Path manifestFile = tempDir.resolve("manifest_" + RandomUtils.nextInt() + ".yaml"); Files.writeString(manifestFile, manifest); - mockManifest(resolver, manifestFile.toUri().toURL(), gav); - } - - private void mockManifest(MavenVersionsResolver resolver, URL manifestUrl, String gavString) throws IOException { final String[] splitGav = gavString.split(":"); final MavenCoordinate gav = new MavenCoordinate(splitGav[0], splitGav[1], splitGav.length == 3 ? splitGav[2] : null); - when(resolver.resolveChannelMetadata(eq(List.of(ChannelManifestCoordinate.create(null, gav))))) - .thenReturn(List.of(manifestUrl)); + + when(resolver.getAllVersions(gav.getGroupId(), gav.getArtifactId(), ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER)) + .thenReturn(Set.of(splitGav.length == 3 ? gav.getVersion() : "1.0.0")); + when(resolver.resolveArtifact(gav.getGroupId(), gav.getArtifactId(), ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER, + splitGav.length == 3 ? gav.getVersion() : "1.0.0")) + .thenReturn(manifestFile.toFile()); } } diff --git a/core/src/test/java/org/wildfly/channel/ChannelSessionTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelSessionTestCase.java index 0bcc08f6..ee45f64d 100644 --- a/core/src/test/java/org/wildfly/channel/ChannelSessionTestCase.java +++ b/core/src/test/java/org/wildfly/channel/ChannelSessionTestCase.java @@ -97,8 +97,8 @@ public static List mockChannel(MavenVersionsResolver resolver, Path tem String manifest = manifests[i]; Path manifestFile = Files.writeString(tempDir.resolve("manifest" + i +".yaml"), manifest); - when(resolver.resolveChannelMetadata(eq(List.of(new ChannelManifestCoordinate("org.channels", "channel" + i, "1.0.0"))))) - .thenReturn(List.of(manifestFile.toUri().toURL())); + when(resolver.resolveArtifact("org.channels", "channel" + i, ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER, "1.0.0")) + .thenReturn(manifestFile.toFile()); } return channels; } diff --git a/core/src/test/java/org/wildfly/channel/ChannelWithBlocklistTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelWithBlocklistTestCase.java index 4a0a2c39..41b40592 100644 --- a/core/src/test/java/org/wildfly/channel/ChannelWithBlocklistTestCase.java +++ b/core/src/test/java/org/wildfly/channel/ChannelWithBlocklistTestCase.java @@ -17,6 +17,7 @@ package org.wildfly.channel; import java.io.File; +import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -80,10 +81,8 @@ public void testFindLatestMavenArtifactVersion() throws Exception { " - groupId: org.wildfly\n" + " artifactId: wildfly-ee-galleon-pack\n" + " versionPattern: .*"); - when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test", "test.manifest", "1.0.0")))) - .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); - - mockBlocklistResolution(resolver, "channels/test-blocklist.yaml"); + mockManifest(resolver, "test", "test.manifest", "manifest.yaml"); + mockBlocklist(resolver, "channels/test-blocklist.yaml", "org.wildfly", "wildfly-blocklist", null); when(factory.create(any())).thenReturn(resolver); when(resolver.getAllVersions("org.wildfly", "wildfly-ee-galleon-pack", null, null)) @@ -125,13 +124,10 @@ public void testFindLatestMavenArtifactVersionBlocklistDoesntExist() throws Exce " - groupId: org.wildfly\n" + " artifactId: '*'\n" + " versionPattern: '25\\.\\d+\\.\\d+.Final'"); - when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test", "test.manifest", "1.0.0")))) - .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); - - when(resolver.resolveChannelMetadata(List.of(new BlocklistCoordinate("org.wildfly", "wildfly-blocklist")))) - .thenReturn(Collections.emptyList()); + mockManifest(resolver, "test", "test.manifest", "manifest.yaml"); when(factory.create(any())).thenReturn(resolver); + // return empty version list when blocklist is queried when(resolver.getAllVersions("org.wildfly", "wildfly-blocklist", BlocklistCoordinate.EXTENSION, BlocklistCoordinate.CLASSIFIER)).thenReturn(Collections.emptySet()); when(resolver.getAllVersions("org.wildfly", "wildfly-ee-galleon-pack", null, null)) @@ -173,10 +169,8 @@ public void testFindLatestMavenArtifactVersionWithWildcardBlocklist() throws Exc " - groupId: org.wildfly\n" + " artifactId: '*'\n" + " versionPattern: '25\\.\\d+\\.\\d+.Final'"); - when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test", "test.manifest", "1.0.0")))) - .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); - - mockBlocklistResolution(resolver, "channels/test-blocklist-with-wildcards.yaml"); + mockManifest(resolver, "test", "test.manifest", "manifest.yaml"); + mockBlocklist(resolver, "channels/test-blocklist-with-wildcards.yaml", "org.wildfly", "wildfly-blocklist", null); when(factory.create(any())).thenReturn(resolver); when(resolver.getAllVersions("org.wildfly", "wildfly-ee-galleon-pack", null, null)) @@ -218,10 +212,9 @@ public void testFindLatestMavenArtifactVersionBlocklistsAllVersionsException() t " - groupId: org.wildfly\n" + " artifactId: '*'\n" + " versionPattern: '25\\.\\d+\\.\\d+.Final'"); - when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test", "test.manifest", "1.0.0")))) - .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); + mockManifest(resolver, "test", "test.manifest", "manifest.yaml"); - mockBlocklistResolution(resolver, "channels/test-blocklist.yaml"); + mockBlocklist(resolver, "channels/test-blocklist.yaml", "org.wildfly", "wildfly-blocklist", null); when(factory.create(any())).thenReturn(resolver); when(resolver.getAllVersions("org.wildfly", "wildfly-ee-galleon-pack", null, null)).thenReturn(new HashSet<>(singleton("25.0.1.Final"))); @@ -266,10 +259,9 @@ public void testResolveLatestMavenArtifact() throws Exception { " - groupId: org.wildfly\n" + " artifactId: '*'\n" + " versionPattern: '25\\.\\d+\\.\\d+.Final'"); - when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test", "test.manifest", "1.0.0")))) - .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); + mockManifest(resolver, "test", "test.manifest", "manifest.yaml"); - mockBlocklistResolution(resolver, "channels/test-blocklist.yaml"); + mockBlocklist(resolver, "channels/test-blocklist.yaml", "org.wildfly", "wildfly-blocklist", null); File resolvedArtifactFile = mock(File.class); @@ -321,10 +313,8 @@ public void testResolveLatestMavenArtifactThrowUnresolvedMavenArtifactException( " - groupId: org.wildfly\n" + " artifactId: '*'\n" + " versionPattern: '25\\.\\d+\\.\\d+.Final'"); - when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test", "test.manifest", "1.0.0")))) - .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); - - mockBlocklistResolution(resolver, "channels/test-blocklist.yaml"); + mockManifest(resolver, "test", "test.manifest", "manifest.yaml"); + mockBlocklist(resolver, "channels/test-blocklist.yaml", "org.wildfly", "wildfly-blocklist", null); when(factory.create(any())).thenReturn(resolver); when(resolver.getAllVersions("org.wildfly", "wildfly-ee-galleon-pack", null, null)).thenReturn(new HashSet<>(Set.of("25.0.1.Final","26.0.0.Final"))); @@ -341,11 +331,26 @@ public void testResolveLatestMavenArtifactThrowUnresolvedMavenArtifactException( verify(resolver, times(2)).close(); } - private void mockBlocklistResolution(MavenVersionsResolver resolver, String fileName) { - when(resolver.getAllVersions("org.wildfly", "wildfly-blocklist", BlocklistCoordinate.EXTENSION, BlocklistCoordinate.CLASSIFIER)) + private void mockBlocklist(MavenVersionsResolver resolver, String blocklistFileLocation, String groupId, String artifactId, String version) throws URISyntaxException { +// when(resolver.resolveChannelMetadata(List.of(new BlocklistCoordinate("org.wildfly", "wildfly-blocklist")))) +// .thenReturn(List.of(this.getClass().getClassLoader().getResource("channels/test-blocklist.yaml"))); + + if (version == null) { + when(resolver.getAllVersions(groupId, artifactId, BlocklistCoordinate.EXTENSION, + BlocklistCoordinate.CLASSIFIER)) + .thenReturn(Set.of("1.0.0")); + version = "1.0.0"; + } + when(resolver.resolveArtifact(groupId, artifactId, BlocklistCoordinate.EXTENSION, + BlocklistCoordinate.CLASSIFIER, version)) + .thenReturn(new File(this.getClass().getClassLoader().getResource(blocklistFileLocation).toURI())); + } + + private void mockManifest(MavenVersionsResolver resolver, String groupId, String artifactId, String manifestFileName) { + when(resolver.getAllVersions(groupId, artifactId, ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER)) .thenReturn(Set.of("1.0.0")); - when(resolver.resolveChannelMetadata(List.of(new BlocklistCoordinate("org.wildfly", "wildfly-blocklist", "1.0.0")))) - .thenReturn(List.of(this.getClass().getClassLoader().getResource(fileName))); + when(resolver.resolveArtifact(groupId, artifactId, ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER, "1.0.0")) + .thenReturn(tempDir.resolve(manifestFileName).toFile()); } @Test @@ -379,10 +384,9 @@ public void testResolveMavenArtifactsFromOneChannel() throws Exception { " - groupId: org.wildfly\n" + " artifactId: wildfly-cli\n" + " version: \"26.0.0.Final\""); - when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test", "test.manifest", "1.0.0")))) - .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); + mockManifest(resolver, "test", "test.manifest", "manifest.yaml"); - mockBlocklistResolution(resolver, "channels/test-blocklist.yaml"); + mockBlocklist(resolver, "channels/test-blocklist.yaml", "org.wildfly", "wildfly-blocklist", null); File resolvedArtifactFile1 = mock(File.class); File resolvedArtifactFile2 = mock(File.class); @@ -444,8 +448,8 @@ public void testFindLatestMavenArtifactVersionInRequiredChannel() throws Excepti " - groupId: org.wildfly\n" + " artifactId: wildfly-ee-galleon-pack\n" + " versionPattern: \".*\""); - when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("org.test", "required-manifest", "1.0.0")))) - .thenReturn(List.of(tempDir.resolve("required-manifest.yaml").toUri().toURL())); + mockManifest(resolver, "org.test", "required-manifest", + "required-manifest.yaml"); Files.writeString(tempDir.resolve("manifest.yaml"), "schemaVersion: " + ChannelManifestMapper.CURRENT_SCHEMA_VERSION + "\n" + @@ -454,11 +458,8 @@ public void testFindLatestMavenArtifactVersionInRequiredChannel() throws Excepti " - groupId: org.wildfly\n" + " artifactId: wildfly-ee-galleon-pack\n" + " versionPattern: \".*\""); - when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("org.test", "base-manifest", "1.0.0")))) - .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); - - when(resolver.resolveChannelMetadata(List.of(new BlocklistCoordinate("org.wildfly", "wildfly-blocklist", "1.2.3")))) - .thenReturn(List.of(this.getClass().getClassLoader().getResource("channels/test-blocklist.yaml"))); + mockManifest(resolver, "org.test", "base-manifest", "manifest.yaml"); + mockBlocklist(resolver, "channels/test-blocklist.yaml", "org.wildfly", "wildfly-blocklist", "1.2.3"); when(factory.create(any())).thenReturn(resolver); when(resolver.getAllVersions("org.wildfly", "wildfly-ee-galleon-pack", null, null)) @@ -500,10 +501,8 @@ public void testChannelWithInvalidBlacklist() throws Exception { " - groupId: org.wildfly\n" + " artifactId: '*'\n" + " versionPattern: '25\\.\\d+\\.\\d+.Final'"); - when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test", "test.manifest", "1.0.0")))) - .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); - - mockBlocklistResolution(resolver, "channels/invalid-blocklist.yaml"); + mockManifest(resolver, "test", "test.manifest", "manifest.yaml"); + mockBlocklist(resolver, "channels/invalid-blocklist.yaml", "org.wildfly", "wildfly-blocklist", null); when(factory.create(any())).thenReturn(resolver); diff --git a/core/src/test/java/org/wildfly/channel/ChannelWithRequirementsTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelWithRequirementsTestCase.java index 36ca5c83..bace5645 100644 --- a/core/src/test/java/org/wildfly/channel/ChannelWithRequirementsTestCase.java +++ b/core/src/test/java/org/wildfly/channel/ChannelWithRequirementsTestCase.java @@ -26,12 +26,14 @@ import java.io.File; import java.io.IOException; +import java.net.URISyntaxException; import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.HashSet; import java.util.List; +import java.util.Set; import org.apache.commons.lang3.RandomUtils; import org.junit.jupiter.api.Assertions; @@ -54,21 +56,20 @@ public void testChannelWhichRequiresAnotherChannel() throws Exception { MavenVersionsResolver resolver = mock(MavenVersionsResolver.class); ClassLoader tccl = Thread.currentThread().getContextClassLoader(); - File resolvedArtifactFile = mock(File.class); URL resolvedRequiredManifestURL = tccl.getResource("channels/required-manifest.yaml"); + File resolvedArtifactFile = new File(resolvedRequiredManifestURL.toURI()); when(factory.create(any())) .thenReturn(resolver); when(resolver.getAllVersions("test.channels", "required-manifest", "yaml", "manifest")) .thenReturn(new HashSet<>(Arrays.asList("1", "2", "3"))); - when(resolver.resolveArtifact("org.example", "required-manifest", "yaml", "manifest", "3")) + when(resolver.resolveArtifact("test.channels", "required-manifest", "yaml", "manifest", "3")) .thenReturn(resolvedArtifactFile); when(resolver.getAllVersions("org.example", "foo-bar", null, null)) .thenReturn(new HashSet<>(Arrays.asList("1.0.0.Final, 1.1.0.Final", "1.2.0.Final"))); when(resolver.resolveArtifact("org.example", "foo-bar", null, null, "1.2.0.Final")) .thenReturn(resolvedArtifactFile); - when(resolver.resolveChannelMetadata(any())).thenReturn(List.of(resolvedRequiredManifestURL)); String baseManifest = "schemaVersion: " + ChannelManifestMapper.CURRENT_SCHEMA_VERSION + "\n" + "name: My manifest\n" + @@ -104,6 +105,11 @@ public void testChannelWhichRequiresAnotherChannel() throws Exception { } } + private static void mockManifest(MavenVersionsResolver resolver, URL resolvedRequiredManifestURL) throws URISyntaxException { + when(resolver.resolveArtifact(any(), any(), eq(ChannelManifest.EXTENSION), eq(ChannelManifest.CLASSIFIER), any())) + .thenReturn(new File(resolvedRequiredManifestURL.toURI())); + } + /** * Test that specific version of required channel is used when required */ @@ -122,7 +128,7 @@ public void testChannelWhichRequiresAnotherVersionedChannel() throws Exception { .thenReturn(resolver); when(resolver.resolveArtifact("org.example", "foo-bar", null, null, "1.2.0.Final")) .thenReturn(resolvedArtifactFile); - when(resolver.resolveChannelMetadata(any())).thenReturn(List.of(resolvedRequiredManifestURL)); + mockManifest(resolver, resolvedRequiredManifestURL); String baseManifest = "schemaVersion: " + ChannelManifestMapper.CURRENT_SCHEMA_VERSION + "\n" + "name: My manifest\n" + @@ -190,8 +196,7 @@ public void testRequiringChannelOverridesStreamFromRequiredChannel() throws Exce .thenReturn(resolvedArtifactFile120Final); when(resolver.resolveArtifact("org.example", "foo-bar", null, null, "2.0.0.Final")) .thenReturn(resolvedArtifactFile200Final); - when(resolver.resolveChannelMetadata(eq(List.of(new ChannelManifestCoordinate("test.channels", "required-manifest", "1.0.0"))))) - .thenReturn(List.of(resolvedRequiredManifestURL)); + mockManifest(resolver, resolvedRequiredManifestURL, "test.channels:required-manifest:1.0.0"); // The requiring channel requires newer version of foo-bar artifact List channels = ChannelMapper.fromString( @@ -563,11 +568,19 @@ public void testChannelMultipleRequirements() throws Exception { } } - private void mockManifest(MavenVersionsResolver resolver, String manifest, String gav) throws IOException { + private void mockManifest(MavenVersionsResolver resolver, String manifest, String gavString) throws IOException { Path manifestFile = tempDir.resolve("manifest_" + RandomUtils.nextInt() + ".yaml"); Files.writeString(manifestFile, manifest); - mockManifest(resolver, manifestFile.toUri().toURL(), gav); + final String[] splitGav = gavString.split(":"); + final MavenCoordinate gav = new MavenCoordinate(splitGav[0], splitGav[1], splitGav.length == 3 ? splitGav[2] : null); + if (gav.getVersion() == null) { + when(resolver.getAllVersions(gav.getGroupId(), gav.getArtifactId(), ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER)) + .thenReturn(Set.of("1.0.0")); + } + when(resolver.resolveArtifact(gav.getGroupId(), gav.getArtifactId(), ChannelManifest.EXTENSION, + ChannelManifest.CLASSIFIER, gav.getVersion() == null ? "1.0.0" : gav.getVersion())) + .thenReturn(manifestFile.toFile()); } @Test @@ -581,8 +594,7 @@ public void testRequiredChannelIgnoresNoStreamStrategy() throws Exception { File resolvedArtifactFile = mock(File.class); URL resolvedRequiredManifestURL = tccl.getResource("channels/required-manifest.yaml"); - when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("org.test", "required-manifest", "1.0.0")))) - .thenReturn(List.of(resolvedRequiredManifestURL)); + mockManifest(resolver, resolvedRequiredManifestURL, "org.test:required-manifest:1.0.0"); when(factory.create(any())) .thenReturn(resolver); @@ -617,10 +629,15 @@ public void testRequiredChannelIgnoresNoStreamStrategy() throws Exception { } } - private void mockManifest(MavenVersionsResolver resolver, URL manifestUrl, String gavString) throws IOException { + private void mockManifest(MavenVersionsResolver resolver, URL manifestUrl, String gavString) throws URISyntaxException { final String[] splitGav = gavString.split(":"); final MavenCoordinate gav = new MavenCoordinate(splitGav[0], splitGav[1], splitGav.length == 3 ? splitGav[2] : null); - when(resolver.resolveChannelMetadata(eq(List.of(ChannelManifestCoordinate.create(null, gav))))) - .thenReturn(List.of(manifestUrl)); + if (gav.getVersion() == null) { + when(resolver.getAllVersions(gav.getGroupId(), gav.getArtifactId(), ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER)) + .thenReturn(Set.of("1.0.0")); + } + when(resolver.resolveArtifact(gav.getGroupId(), gav.getArtifactId(), ChannelManifest.EXTENSION, + ChannelManifest.CLASSIFIER, gav.getVersion() == null ? "1.0.0" : gav.getVersion())) + .thenReturn(new File(manifestUrl.toURI())); } } From 31e87793297c922e29de464bff69610717f4112c Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Tue, 30 Jul 2024 17:40:32 +0200 Subject: [PATCH 04/10] Add implementation of SignatureValidator --- gpg-validator/pom.xml | 56 ++++ .../org/wildfly/channel/gpg/GpgKeystore.java | 28 ++ .../channel/gpg/GpgSignatureValidator.java | 282 ++++++++++++++++ .../org/wildfly/channel/gpg/Keyserver.java | 96 ++++++ .../gpg/KeystoreOperationException.java | 34 ++ .../gpg/GpgSignatureValidatorTest.java | 312 ++++++++++++++++++ pom.xml | 25 ++ 7 files changed, 833 insertions(+) create mode 100644 gpg-validator/pom.xml create mode 100644 gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgKeystore.java create mode 100644 gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidator.java create mode 100644 gpg-validator/src/main/java/org/wildfly/channel/gpg/Keyserver.java create mode 100644 gpg-validator/src/main/java/org/wildfly/channel/gpg/KeystoreOperationException.java create mode 100644 gpg-validator/src/test/java/org/wildfly/channel/gpg/GpgSignatureValidatorTest.java diff --git a/gpg-validator/pom.xml b/gpg-validator/pom.xml new file mode 100644 index 00000000..38760cef --- /dev/null +++ b/gpg-validator/pom.xml @@ -0,0 +1,56 @@ + + + + channel-parent + org.wildfly.channel + 1.1.1.Final-SNAPSHOT + + 4.0.0 + + gpg-validator + WildFly Channel - GPG Validator + + + + org.bouncycastle + bcpg-jdk18on + + + org.bouncycastle + bcprov-jdk18on + + + org.bouncycastle + bcutil-jdk18on + + + org.wildfly.channel + channel-core + + + org.pgpainless + pgpainless-core + test + + + + org.jboss.logging + jboss-logging + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.assertj + assertj-core + test + + + diff --git a/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgKeystore.java b/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgKeystore.java new file mode 100644 index 00000000..76d2cb4b --- /dev/null +++ b/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgKeystore.java @@ -0,0 +1,28 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.wildfly.channel.gpg; + +import org.bouncycastle.openpgp.PGPPublicKey; + +import java.util.List; + +public interface GpgKeystore { + + PGPPublicKey get(String keyID); + + boolean add(List publicKey) throws KeystoreOperationException; +} diff --git a/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidator.java b/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidator.java new file mode 100644 index 00000000..45591c68 --- /dev/null +++ b/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidator.java @@ -0,0 +1,282 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.wildfly.channel.gpg; + +import java.io.BufferedInputStream; +import java.io.DataInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.net.URI; +import java.net.URLConnection; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +import org.bouncycastle.bcpg.ArmoredInputStream; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPObjectFactory; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.openpgp.PGPSignatureList; +import org.bouncycastle.openpgp.PGPSignatureSubpacketVector; +import org.bouncycastle.openpgp.PGPUtil; +import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider; +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator; +import org.jboss.logging.Logger; +import org.wildfly.channel.ArtifactCoordinate; +import org.wildfly.channel.MavenArtifact; +import org.wildfly.channel.spi.SignatureResult; +import org.wildfly.channel.spi.SignatureValidator; + +public class GpgSignatureValidator implements SignatureValidator { + private static final Logger LOG = Logger.getLogger(GpgSignatureValidator.class); + private final GpgKeystore keystore; + private final Keyserver keyserver; + + private SignatureValidatorListener listener = new SignatureValidatorListener() { + + @Override + public void artifactSignatureCorrect(MavenArtifact artifact, PGPPublicKey publicKey) { + // noop + } + + @Override + public void artifactSignatureInvalid(MavenArtifact artifact, PGPPublicKey publicKey) { + // noop + } + }; + + public GpgSignatureValidator(GpgKeystore keystore) { + this(keystore, new Keyserver(Collections.emptyList())); + } + + public GpgSignatureValidator(GpgKeystore keystore, Keyserver keyserver) { + this.keystore = keystore; + this.keyserver = keyserver; + } + + public void addListener(SignatureValidatorListener listener) { + this.listener = listener; + } + + @Override + public SignatureResult validateSignature(MavenArtifact artifact, File signature, List gpgUrls) throws SignatureException { + Objects.requireNonNull(artifact); + Objects.requireNonNull(signature); + + final PGPSignature pgpSignature; + try { + pgpSignature = readSignatureFile(signature); + } catch (IOException e) { + throw new SignatureException("Could not find signature in provided signature file", e, SignatureResult.noSignature(toArtifactCoordinate(artifact))); + } + + if (pgpSignature == null) { + LOG.error("Could not read the signature in provided signature file"); + return SignatureResult.noSignature(toArtifactCoordinate(artifact)); + } + + final String keyID = Long.toHexString(pgpSignature.getKeyID()).toUpperCase(Locale.ROOT); + + final PGPPublicKey publicKey; + if (keystore.get(keyID) != null) { + publicKey = keystore.get(keyID); + } else { + List pgpPublicKeys = null; + PGPPublicKey key = null; + try { + final PGPPublicKeyRing keyRing = keyserver.downloadKey(keyID); + if (keyRing != null) { + final Iterator publicKeys = keyRing.getPublicKeys(); + key = keyRing.getPublicKey(new BigInteger(keyID, 16).longValue()); + pgpPublicKeys = new ArrayList<>(); + while (publicKeys.hasNext()) { + pgpPublicKeys.add(publicKeys.next()); + } + } + } catch (PGPException | IOException e) { + throw new SignatureException("Unable to parse the certificate downloaded from keyserver", e, SignatureResult.noSignature(artifact)); + } + + if (key == null) { + for (String gpgUrl : gpgUrls) { + try { + pgpPublicKeys = downloadPublicKey(gpgUrl); + } catch (IOException e) { + throw new SignatureException("Unable to parse the certificate downloaded from " + gpgUrl, e, + SignatureResult.noSignature(artifact)); + } + if (pgpPublicKeys.stream().anyMatch(k -> k.getKeyID() == pgpSignature.getKeyID())) { + key = pgpPublicKeys.stream().filter(k -> k.getKeyID() == pgpSignature.getKeyID()).findFirst().get(); + break; + } + } + } + if (key == null) { + final ArtifactCoordinate coord = toArtifactCoordinate(artifact); + return SignatureResult.noMatchingCertificate(coord, keyID); + } else { + if (keystore.add(pgpPublicKeys)) { + publicKey = key; + } else { + final ArtifactCoordinate coord = toArtifactCoordinate(artifact); + return SignatureResult.noMatchingCertificate(coord, keyID); + } + } + } + + final Iterator subKeys = publicKey.getSignaturesOfType(PGPSignature.SUBKEY_BINDING); + while (subKeys.hasNext()) { + final PGPSignature subKey = subKeys.next(); + final PGPPublicKey masterKey = keystore.get(Long.toHexString(subKey.getKeyID()).toUpperCase(Locale.ROOT)); + if (masterKey.hasRevocation()) { + return SignatureResult.revoked(toArtifactCoordinate(artifact), keyID, getRevocationReason(publicKey)); + } + } + + if (publicKey.hasRevocation()) { + return SignatureResult.revoked(toArtifactCoordinate(artifact), keyID, getRevocationReason(publicKey)); + } + + if (publicKey.getValidSeconds() > 0) { + final Instant expiry = Instant.from(publicKey.getCreationTime().toInstant().plus(publicKey.getValidSeconds(), ChronoUnit.SECONDS)); + if (expiry.isBefore(Instant.now())) { + return SignatureResult.expired(toArtifactCoordinate(artifact), keyID); + } + } + + try { + pgpSignature.init(new BcPGPContentVerifierBuilderProvider(), publicKey); + } catch (PGPException e) { + throw new SignatureException("Unable to verify the signature using key " + keyID, e, + SignatureResult.invalid(artifact)); + } + + final SignatureResult result = verifyFile(artifact, pgpSignature); + + if (result.getResult() == SignatureResult.Result.OK) { + listener.artifactSignatureCorrect(artifact, publicKey); + } else { + listener.artifactSignatureInvalid(artifact, publicKey); + } + + return result; + } + + private static ArtifactCoordinate toArtifactCoordinate(MavenArtifact artifact) { + final ArtifactCoordinate coord = new ArtifactCoordinate(artifact.getGroupId(), artifact.getArtifactId(), artifact.getExtension(), artifact.getClassifier(), artifact.getVersion()); + return coord; + } + + private static String getRevocationReason(PGPPublicKey publicKey) { + Iterator keySignatures = publicKey.getSignaturesOfType(PGPSignature.KEY_REVOCATION); + String revocationDescription = null; + while (keySignatures.hasNext()) { + final PGPSignature sign = keySignatures.next(); + if (sign.getSignatureType() == PGPSignature.KEY_REVOCATION) { + final PGPSignatureSubpacketVector hashedSubPackets = sign.getHashedSubPackets(); + revocationDescription = hashedSubPackets.getRevocationReason().getRevocationDescription(); + } + } + return revocationDescription; + } + + private static SignatureResult verifyFile(MavenArtifact mavenArtifact, PGPSignature pgpSignature) throws SignatureException { + // Read file to verify + byte[] data = new byte[1024]; + InputStream inputStream = null; + try { + inputStream = new DataInputStream(new BufferedInputStream(new FileInputStream(mavenArtifact.getFile()))); + while (true) { + int bytesRead = inputStream.read(data, 0, 1024); + if (bytesRead == -1) + break; + pgpSignature.update(data, 0, bytesRead); + } + inputStream.close(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + // Verify the signature + try { + if (!pgpSignature.verify()) { + return SignatureResult.invalid(mavenArtifact); + } else { + return SignatureResult.ok(); + } + } catch (PGPException e) { + throw new SignatureException("Unable to verify the file signature", e, SignatureResult.invalid(mavenArtifact)); + } + } + + private static PGPSignature readSignatureFile(File signatureFile) throws IOException { + PGPSignature pgpSignature = null; + try (InputStream decoderStream = PGPUtil.getDecoderStream(new FileInputStream(signatureFile))) { + PGPObjectFactory pgpObjectFactory = new PGPObjectFactory(decoderStream, new JcaKeyFingerprintCalculator()); + Object o = pgpObjectFactory.nextObject(); + if (o instanceof PGPSignatureList) { + PGPSignatureList signatureList = (PGPSignatureList) o; + if (signatureList.isEmpty()) { + throw new RuntimeException("signatureList must not be empty"); + } + pgpSignature = signatureList.get(0); + } else if (o instanceof PGPSignature) { + pgpSignature = (PGPSignature) o; + } + } + return pgpSignature; + } + + private static List downloadPublicKey(String signatureUrl) throws IOException { + final URI uri = URI.create(signatureUrl); + final InputStream inputStream; + if (uri.getScheme().equals("classpath")) { + final String keyPath = uri.getSchemeSpecificPart(); + inputStream = GpgSignatureValidator.class.getClassLoader().getResourceAsStream(keyPath); + } else { + final URLConnection urlConnection = uri.toURL().openConnection(); + urlConnection.connect(); + inputStream = urlConnection.getInputStream(); + } + try (InputStream decoderStream = new ArmoredInputStream(inputStream)) { + final PGPPublicKeyRing pgpPublicKeys = new PGPPublicKeyRing(decoderStream, new JcaKeyFingerprintCalculator()); + final ArrayList res = new ArrayList<>(); + final Iterator publicKeys = pgpPublicKeys.getPublicKeys(); + while (publicKeys.hasNext()) { + res.add(publicKeys.next()); + } + return res; + } + } + + public interface SignatureValidatorListener { + + void artifactSignatureCorrect(MavenArtifact artifact, PGPPublicKey publicKey); + + void artifactSignatureInvalid(MavenArtifact artifact, PGPPublicKey publicKey); + } +} diff --git a/gpg-validator/src/main/java/org/wildfly/channel/gpg/Keyserver.java b/gpg-validator/src/main/java/org/wildfly/channel/gpg/Keyserver.java new file mode 100644 index 00000000..16def58c --- /dev/null +++ b/gpg-validator/src/main/java/org/wildfly/channel/gpg/Keyserver.java @@ -0,0 +1,96 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.wildfly.channel.gpg; + +import org.apache.http.HttpEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection; +import org.bouncycastle.openpgp.PGPUtil; +import org.bouncycastle.openpgp.operator.bc.BcKeyFingerprintCalculator; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.net.URI; +import java.net.URL; +import java.util.List; + +public class Keyserver { + + private static final String LOOKUP_PATH = "/pks/lookup"; + + private final List servers; + + public Keyserver(List serverUrls) { + this.servers = serverUrls; + } + + public PGPPublicKeyRing downloadKey(String keyID) throws PGPException, IOException { + for (URL server : servers) { + final PGPPublicKeyRing publicKey = tryDownloadKey(server, keyID); + if (publicKey != null) { + return publicKey; + } + } + return null; + } + + private PGPPublicKeyRing tryDownloadKey(URL serverUrl, String keyID) throws IOException, PGPException { + final String protocol; + if (serverUrl.getProtocol().equals("hkps")) { + protocol = "https"; + } else if (serverUrl.getProtocol().equals("hkp")) { + protocol = "http"; + } else { + protocol = serverUrl.getProtocol(); + } + + final String host = serverUrl.getHost(); + final int port = serverUrl.getPort(); + final String path = serverUrl.getPath(); + + final URI keyUri = URI.create(protocol + "://" + host + ":" + port + "/" + path + LOOKUP_PATH + "?" + getQueryStringForGetKey(keyID)); + + final HttpUriRequest request = new HttpGet(keyUri); + + try (final CloseableHttpClient client = HttpClientBuilder.create().build(); + final CloseableHttpResponse response = client.execute(request)) { + if (response.getStatusLine().getStatusCode() == 200) { + + final HttpEntity responseEntity = response.getEntity(); + try (InputStream inputStream = responseEntity.getContent()) { + final InputStream keyIn = PGPUtil.getDecoderStream(inputStream); + final PGPPublicKeyRingCollection pgpRing = new PGPPublicKeyRingCollection(keyIn, new BcKeyFingerprintCalculator()); + final BigInteger bi = new BigInteger(keyID, 16); + return pgpRing.getPublicKeyRing(bi.longValue()); + } + } else { + return null; + } + } + } + + private static String getQueryStringForGetKey(String keyID) { + return String.format("op=get&options=mr&search=0x%s", keyID); + } +} diff --git a/gpg-validator/src/main/java/org/wildfly/channel/gpg/KeystoreOperationException.java b/gpg-validator/src/main/java/org/wildfly/channel/gpg/KeystoreOperationException.java new file mode 100644 index 00000000..c052bb7d --- /dev/null +++ b/gpg-validator/src/main/java/org/wildfly/channel/gpg/KeystoreOperationException.java @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.wildfly.channel.gpg; + +public class KeystoreOperationException extends RuntimeException { + public KeystoreOperationException() { + } + + public KeystoreOperationException(String message) { + super(message); + } + + public KeystoreOperationException(String message, Throwable cause) { + super(message, cause); + } + + public KeystoreOperationException(Throwable cause) { + super(cause); + } +} diff --git a/gpg-validator/src/test/java/org/wildfly/channel/gpg/GpgSignatureValidatorTest.java b/gpg-validator/src/test/java/org/wildfly/channel/gpg/GpgSignatureValidatorTest.java new file mode 100644 index 00000000..b4f8f186 --- /dev/null +++ b/gpg-validator/src/test/java/org/wildfly/channel/gpg/GpgSignatureValidatorTest.java @@ -0,0 +1,312 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.wildfly.channel.gpg; + +import org.assertj.core.api.Assertions; +import org.bouncycastle.bcpg.ArmoredOutputStream; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.util.io.Streams; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; +import org.pgpainless.PGPainless; +import org.pgpainless.algorithm.KeyFlag; +import org.pgpainless.encryption_signing.EncryptionStream; +import org.pgpainless.encryption_signing.ProducerOptions; +import org.pgpainless.encryption_signing.SigningOptions; +import org.pgpainless.key.SubkeyIdentifier; +import org.pgpainless.key.generation.KeySpec; +import org.pgpainless.key.generation.type.KeyType; +import org.pgpainless.key.generation.type.rsa.RsaLength; +import org.pgpainless.key.protection.UnprotectedKeysProtector; +import org.pgpainless.key.util.RevocationAttributes; +import org.wildfly.channel.ArtifactCoordinate; +import org.wildfly.channel.MavenArtifact; +import org.wildfly.channel.spi.SignatureResult; +import org.wildfly.channel.spi.SignatureValidator; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public class GpgSignatureValidatorTest { + @TempDir + Path tempDir; + + private PGPSecretKeyRing pgpValidKeys; + private PGPSecretKeyRing pgpAttackerKeys; + private PGPSecretKeyRing pgpExpiredKeys; + private TestKeystore keystore = new TestKeystore(); + private GpgSignatureValidator validator; + private MavenArtifact anArtifact; + private File signatureFile; + + @BeforeEach + public void setUp() throws Exception { + pgpValidKeys = PGPainless.generateKeyRing().simpleRsaKeyRing("Test ", RsaLength._4096); + pgpAttackerKeys = PGPainless.generateKeyRing().simpleRsaKeyRing("Fake ", RsaLength._4096); + pgpExpiredKeys = PGPainless.buildKeyRing() + .setPrimaryKey(KeySpec.getBuilder(KeyType.RSA(RsaLength._4096), KeyFlag.CERTIFY_OTHER, KeyFlag.SIGN_DATA, KeyFlag.ENCRYPT_COMMS)) + .addUserId("Test ") + .setExpirationDate(new Date(System.currentTimeMillis()+1_000)) + .build(); + + keystore = new TestKeystore(); + validator = new GpgSignatureValidator(keystore); + + File artifactFile = tempDir.resolve("test-one.jar").toFile(); + Files.writeString(artifactFile.toPath(), "test"); + anArtifact = new MavenArtifact("org.test", "test-one", "jar", null, "1.0.0", artifactFile); + + signatureFile = signFile(anArtifact.getFile(), pgpValidKeys); + } + + @Test + public void validSignatureIsAccepted() throws Exception { + keystore.using(pgpValidKeys); + + Assertions.assertThat(validator.validateSignature(anArtifact, signatureFile, Collections.emptyList())) + .hasFieldOrPropertyWithValue("result", SignatureResult.Result.OK); + } + + @Test + public void invalidSignatureReturnsErrorStatus() throws Exception { + keystore.using(pgpValidKeys); + + final File signatureFile = signFile(anArtifact.getFile(), pgpAttackerKeys); + + Assertions.assertThat(validator.validateSignature(anArtifact, signatureFile, Collections.emptyList())) + .hasFieldOrPropertyWithValue("result", SignatureResult.Result.NO_MATCHING_CERT) + .hasFieldOrPropertyWithValue("artifact", toCoord()); + } + + @Test + public void expiredSignatureReturnsError() throws Exception { + keystore.using(pgpExpiredKeys); + + // the certificate has to have an expiry date at least now()+1 second, otherwise it's treated as never-expiring + // wait for certificate to expire + while (!isExpired(pgpExpiredKeys.getPublicKey())) { + Thread.sleep(100); + } + + final File signatureFile = signFile(anArtifact.getFile(), pgpExpiredKeys); + + Assertions.assertThat(validator.validateSignature(anArtifact, signatureFile, Collections.emptyList())) + .hasFieldOrPropertyWithValue("result", SignatureResult.Result.EXPIRED) + .hasFieldOrPropertyWithValue("artifact", toCoord()) + .hasFieldOrPropertyWithValue("keyId", toHex(pgpExpiredKeys.getPublicKey().getKeyID())); + } + + @Test + public void revokedSignatureReturnsError() throws Exception { + // order of operations matter! sign artifact, revoke the key, init the keystore + final PGPSecretKeyRing pgpExpiredKeys = PGPainless.modifyKeyRing(pgpValidKeys) + .revoke(new UnprotectedKeysProtector(), + RevocationAttributes + .createKeyRevocation() + .withReason(RevocationAttributes.Reason.KEY_COMPROMISED) + .withDescription("The key is revoked")) + .done(); + keystore.using(pgpExpiredKeys); + + Assertions.assertThat(validator.validateSignature(anArtifact, signatureFile, Collections.emptyList())) + .hasFieldOrPropertyWithValue("result", SignatureResult.Result.REVOKED) + .hasFieldOrPropertyWithValue("artifact", toCoord()) + .hasFieldOrPropertyWithValue("keyId", toHex(pgpValidKeys.getPublicKey().getKeyID())) + .hasFieldOrPropertyWithValue("message", "The key is revoked"); + } + + @Test + public void downloadsSignatureIfUrlIsProvided() throws Exception { + keystore.using(Collections.emptyList()); + + // export the public certificate + final File publicCertFile = exportPublicCertificate(pgpValidKeys); + + Assertions.assertThat(validator.validateSignature(anArtifact, signatureFile, List.of(publicCertFile.toURI().toString()))) + .hasFieldOrPropertyWithValue("result", SignatureResult.Result.OK); + + Assertions.assertThat(keystore.getKeys().keySet()) + .containsOnly(toHex(pgpValidKeys.getPublicKey().getKeyID())); + } + + @Test + public void failedSignatureDownloadThrowsException() throws Exception { + keystore.using(Collections.emptyList()); + + // export the public certificate + final File publicCertFile = tempDir.resolve("public.crt").toFile(); + Files.writeString(publicCertFile.toPath(), "I'm not a certificate"); + final String certUrl = publicCertFile.toURI().toString(); + + Assertions.assertThatThrownBy(()->validator.validateSignature(anArtifact, signatureFile, List.of(certUrl))) + .isInstanceOf(SignatureValidator.SignatureException.class) + .hasMessageContainingAll("Unable to parse the certificate downloaded from " + certUrl); + } + + @Test + public void invalidSignatureDownloadedReturnsError() throws Exception { + keystore.using(Collections.emptyList()); + + final File signatureFile = signFile(anArtifact.getFile(), pgpAttackerKeys); + + // export the public certificate + final File publicCertFile = exportPublicCertificate(pgpValidKeys); + + Assertions.assertThat(validator.validateSignature(anArtifact, signatureFile, List.of(publicCertFile.toURI().toString()))) + .hasFieldOrPropertyWithValue("result", SignatureResult.Result.NO_MATCHING_CERT) + .hasFieldOrPropertyWithValue("artifact", toCoord()) + .hasFieldOrPropertyWithValue("keyId", toHex(pgpAttackerKeys.getPublicKey().getKeyID())); + + // no certificates should have been imported + Assertions.assertThat(keystore.getKeys().keySet()) + .isEmpty(); + } + + @Test + public void keystoreRejectingCertificateReturnsError() throws Exception { + final GpgKeystore rejectingKeystore = Mockito.mock(GpgKeystore.class); + Mockito.when(rejectingKeystore.add(Mockito.anyList())).thenReturn(false); + validator = new GpgSignatureValidator(rejectingKeystore); + + final File publicCertFile = exportPublicCertificate(pgpValidKeys); + + Assertions.assertThat(validator.validateSignature(anArtifact, signatureFile, List.of(publicCertFile.toURI().toString()))) + .hasFieldOrPropertyWithValue("result", SignatureResult.Result.NO_MATCHING_CERT) + .hasFieldOrPropertyWithValue("artifact", toCoord()) + .hasFieldOrPropertyWithValue("keyId", toHex(pgpValidKeys.getPublicKey().getKeyID())); + } + + private ArtifactCoordinate toCoord() { + return new ArtifactCoordinate(anArtifact.getGroupId(), anArtifact.getArtifactId(), anArtifact.getExtension(), + anArtifact.getClassifier(), anArtifact.getVersion()); + } + + private File exportPublicCertificate(PGPSecretKeyRing keyRing) throws IOException { + // export the public certificate + final File publicCertFile = tempDir.resolve("public.crt").toFile(); + try (ArmoredOutputStream outStream = new ArmoredOutputStream(new FileOutputStream(publicCertFile))) { + keyRing.getPublicKey().encode(outStream); + } + return publicCertFile; + } + + private boolean isExpired(PGPPublicKey publicKey) { + if (publicKey.getValidSeconds() == 0) { + return false; + } else { + final Instant expiry = Instant.from(publicKey.getCreationTime().toInstant().plus(publicKey.getValidSeconds(), ChronoUnit.SECONDS)); + return expiry.isBefore(Instant.now()); + } + } + + private File signFile(File file, PGPSecretKeyRing pgpSecretKeys) throws PGPException, IOException { + final SigningOptions signOptions = SigningOptions.get() + .addDetachedSignature(new UnprotectedKeysProtector(), pgpSecretKeys); + + final File signatureFile = tempDir.resolve("test-one.jar.asc").toFile(); + final EncryptionStream encryptionStream = PGPainless.encryptAndOrSign() + .onOutputStream(new FileOutputStream(signatureFile)) + .withOptions(ProducerOptions.sign(signOptions)); + + Streams.pipeAll(new FileInputStream(file), encryptionStream); // pipe the data through + encryptionStream.close(); + + // wrap signature in armour + try(FileOutputStream fos = new FileOutputStream(signatureFile); + final ArmoredOutputStream aos = new ArmoredOutputStream(fos)) { + for (SubkeyIdentifier subkeyIdentifier : encryptionStream.getResult().getDetachedSignatures().keySet()) { + final Set pgpSignatures = encryptionStream.getResult().getDetachedSignatures().get(subkeyIdentifier); + for (PGPSignature pgpSignature : pgpSignatures) { + pgpSignature.encode(aos); + } + } + } + return signatureFile; + } + + private static class TestKeystore implements GpgKeystore { + + private final HashMap keys = new HashMap<>(); + + TestKeystore() { + + } + + public void using(PGPSecretKeyRing pgpSecretKeys) { + this.using(PGPainless.extractCertificate(pgpSecretKeys)); + } + + void using(PGPPublicKeyRing pgpPublicKeys) { + keys.clear(); + + final Iterator publicKeys = pgpPublicKeys.getPublicKeys(); + while (publicKeys.hasNext()) { + final PGPPublicKey key = publicKeys.next(); + keys.put(toHex(key.getKeyID()), key); + } + } + + public void using(List publicKeys) { + keys.clear(); + + for (PGPPublicKey key : publicKeys) { + keys.put(toHex(key.getKeyID()), key); + } + } + + public HashMap getKeys() { + return keys; + } + + @Override + public PGPPublicKey get(String keyID) { + return keys.get(keyID); + } + + @Override + public boolean add(List publicKeys) { + for (PGPPublicKey key : publicKeys) { + keys.put(toHex(key.getKeyID()), key); + } + return true; + } + } + + private static String toHex(long keyID) { + return Long.toHexString(keyID).toUpperCase(Locale.ROOT); + } + +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index f4d587d7..799179eb 100644 --- a/pom.xml +++ b/pom.xml @@ -31,10 +31,12 @@ 3.6.1.Final 3.17.0 4.5.14 + 1.78.1 0.19.1 5.14.2 3.9.1 3.26.3 + 1.6.7 3.1.1 1.9.20 3.6.3 @@ -78,6 +80,21 @@ maven-repository-metadata ${version.maven.repository.metadata} + + org.bouncycastle + bcpg-jdk18on + ${version.org.bouncycastle} + + + org.bouncycastle + bcprov-jdk18on + ${version.org.bouncycastle} + + + org.bouncycastle + bcutil-jdk18on + ${version.org.bouncycastle} + org.jboss.logging jboss-logging @@ -113,8 +130,15 @@ org.assertj assertj-core + test ${version.org.assertj} + + org.pgpainless + pgpainless-core + ${version.org.pgpainless} + test + org.wildfly.channel @@ -132,6 +156,7 @@ core maven-resolver + gpg-validator From 743470533f5fc6e0502acc0ba5e364d1403876f5 Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Thu, 1 Aug 2024 10:49:37 +0200 Subject: [PATCH 05/10] Update validator implementation with SPI changes --- .../channel/gpg/GpgSignatureValidator.java | 66 +++++++------ .../gpg/GpgSignatureValidatorTest.java | 99 +++++++++++++------ 2 files changed, 101 insertions(+), 64 deletions(-) diff --git a/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidator.java b/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidator.java index 45591c68..dd6c326f 100644 --- a/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidator.java +++ b/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidator.java @@ -18,8 +18,6 @@ import java.io.BufferedInputStream; import java.io.DataInputStream; -import java.io.File; -import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.math.BigInteger; @@ -50,6 +48,7 @@ import org.wildfly.channel.MavenArtifact; import org.wildfly.channel.spi.SignatureResult; import org.wildfly.channel.spi.SignatureValidator; +import org.wildfly.channel.spi.ValidationResource; public class GpgSignatureValidator implements SignatureValidator { private static final Logger LOG = Logger.getLogger(GpgSignatureValidator.class); @@ -59,12 +58,12 @@ public class GpgSignatureValidator implements SignatureValidator { private SignatureValidatorListener listener = new SignatureValidatorListener() { @Override - public void artifactSignatureCorrect(MavenArtifact artifact, PGPPublicKey publicKey) { + public void artifactSignatureCorrect(ValidationResource artifact, PGPPublicKey publicKey) { // noop } @Override - public void artifactSignatureInvalid(MavenArtifact artifact, PGPPublicKey publicKey) { + public void artifactSignatureInvalid(ValidationResource artifact, PGPPublicKey publicKey) { // noop } }; @@ -83,20 +82,23 @@ public void addListener(SignatureValidatorListener listener) { } @Override - public SignatureResult validateSignature(MavenArtifact artifact, File signature, List gpgUrls) throws SignatureException { - Objects.requireNonNull(artifact); - Objects.requireNonNull(signature); + public SignatureResult validateSignature(ValidationResource artifactSource, InputStream artifactStream, + InputStream signatureStream, List gpgUrls) throws SignatureException { + Objects.requireNonNull(artifactSource); + Objects.requireNonNull(artifactStream); + Objects.requireNonNull(signatureStream); final PGPSignature pgpSignature; try { - pgpSignature = readSignatureFile(signature); + pgpSignature = readSignatureFile(signatureStream); } catch (IOException e) { - throw new SignatureException("Could not find signature in provided signature file", e, SignatureResult.noSignature(toArtifactCoordinate(artifact))); + throw new SignatureException("Could not find signature in provided signature file", e, + SignatureResult.noSignature(artifactSource)); } if (pgpSignature == null) { LOG.error("Could not read the signature in provided signature file"); - return SignatureResult.noSignature(toArtifactCoordinate(artifact)); + return SignatureResult.noSignature(artifactSource); } final String keyID = Long.toHexString(pgpSignature.getKeyID()).toUpperCase(Locale.ROOT); @@ -118,7 +120,8 @@ public SignatureResult validateSignature(MavenArtifact artifact, File signature, } } } catch (PGPException | IOException e) { - throw new SignatureException("Unable to parse the certificate downloaded from keyserver", e, SignatureResult.noSignature(artifact)); + throw new SignatureException("Unable to parse the certificate downloaded from keyserver", e, + SignatureResult.noSignature(artifactSource)); } if (key == null) { @@ -127,7 +130,7 @@ public SignatureResult validateSignature(MavenArtifact artifact, File signature, pgpPublicKeys = downloadPublicKey(gpgUrl); } catch (IOException e) { throw new SignatureException("Unable to parse the certificate downloaded from " + gpgUrl, e, - SignatureResult.noSignature(artifact)); + SignatureResult.noSignature(artifactSource)); } if (pgpPublicKeys.stream().anyMatch(k -> k.getKeyID() == pgpSignature.getKeyID())) { key = pgpPublicKeys.stream().filter(k -> k.getKeyID() == pgpSignature.getKeyID()).findFirst().get(); @@ -136,14 +139,12 @@ public SignatureResult validateSignature(MavenArtifact artifact, File signature, } } if (key == null) { - final ArtifactCoordinate coord = toArtifactCoordinate(artifact); - return SignatureResult.noMatchingCertificate(coord, keyID); + return SignatureResult.noMatchingCertificate(artifactSource, keyID); } else { if (keystore.add(pgpPublicKeys)) { publicKey = key; } else { - final ArtifactCoordinate coord = toArtifactCoordinate(artifact); - return SignatureResult.noMatchingCertificate(coord, keyID); + return SignatureResult.noMatchingCertificate(artifactSource, keyID); } } } @@ -153,18 +154,18 @@ public SignatureResult validateSignature(MavenArtifact artifact, File signature, final PGPSignature subKey = subKeys.next(); final PGPPublicKey masterKey = keystore.get(Long.toHexString(subKey.getKeyID()).toUpperCase(Locale.ROOT)); if (masterKey.hasRevocation()) { - return SignatureResult.revoked(toArtifactCoordinate(artifact), keyID, getRevocationReason(publicKey)); + return SignatureResult.revoked(artifactSource, keyID, getRevocationReason(publicKey)); } } if (publicKey.hasRevocation()) { - return SignatureResult.revoked(toArtifactCoordinate(artifact), keyID, getRevocationReason(publicKey)); + return SignatureResult.revoked(artifactSource, keyID, getRevocationReason(publicKey)); } if (publicKey.getValidSeconds() > 0) { final Instant expiry = Instant.from(publicKey.getCreationTime().toInstant().plus(publicKey.getValidSeconds(), ChronoUnit.SECONDS)); if (expiry.isBefore(Instant.now())) { - return SignatureResult.expired(toArtifactCoordinate(artifact), keyID); + return SignatureResult.expired(artifactSource, keyID); } } @@ -172,15 +173,15 @@ public SignatureResult validateSignature(MavenArtifact artifact, File signature, pgpSignature.init(new BcPGPContentVerifierBuilderProvider(), publicKey); } catch (PGPException e) { throw new SignatureException("Unable to verify the signature using key " + keyID, e, - SignatureResult.invalid(artifact)); + SignatureResult.invalid(artifactSource)); } - final SignatureResult result = verifyFile(artifact, pgpSignature); + final SignatureResult result = verifyFile(artifactSource, artifactStream, pgpSignature); if (result.getResult() == SignatureResult.Result.OK) { - listener.artifactSignatureCorrect(artifact, publicKey); + listener.artifactSignatureCorrect(artifactSource, publicKey); } else { - listener.artifactSignatureInvalid(artifact, publicKey); + listener.artifactSignatureInvalid(artifactSource, publicKey); } return result; @@ -204,12 +205,12 @@ private static String getRevocationReason(PGPPublicKey publicKey) { return revocationDescription; } - private static SignatureResult verifyFile(MavenArtifact mavenArtifact, PGPSignature pgpSignature) throws SignatureException { + private static SignatureResult verifyFile(ValidationResource artifactSource, InputStream artifactStream, PGPSignature pgpSignature) throws SignatureException { // Read file to verify byte[] data = new byte[1024]; InputStream inputStream = null; try { - inputStream = new DataInputStream(new BufferedInputStream(new FileInputStream(mavenArtifact.getFile()))); + inputStream = new DataInputStream(new BufferedInputStream(artifactStream)); while (true) { int bytesRead = inputStream.read(data, 0, 1024); if (bytesRead == -1) @@ -224,19 +225,20 @@ private static SignatureResult verifyFile(MavenArtifact mavenArtifact, PGPSignat // Verify the signature try { if (!pgpSignature.verify()) { - return SignatureResult.invalid(mavenArtifact); + return SignatureResult.invalid(artifactSource); } else { return SignatureResult.ok(); } } catch (PGPException e) { - throw new SignatureException("Unable to verify the file signature", e, SignatureResult.invalid(mavenArtifact)); + throw new SignatureException("Unable to verify the file signature", e, + SignatureResult.invalid(artifactSource)); } } - private static PGPSignature readSignatureFile(File signatureFile) throws IOException { + private static PGPSignature readSignatureFile(InputStream signatureStream) throws IOException { PGPSignature pgpSignature = null; - try (InputStream decoderStream = PGPUtil.getDecoderStream(new FileInputStream(signatureFile))) { - PGPObjectFactory pgpObjectFactory = new PGPObjectFactory(decoderStream, new JcaKeyFingerprintCalculator()); + try (InputStream decoderStream = PGPUtil.getDecoderStream(signatureStream)) { + final PGPObjectFactory pgpObjectFactory = new PGPObjectFactory(decoderStream, new JcaKeyFingerprintCalculator()); Object o = pgpObjectFactory.nextObject(); if (o instanceof PGPSignatureList) { PGPSignatureList signatureList = (PGPSignatureList) o; @@ -275,8 +277,8 @@ private static List downloadPublicKey(String signatureUrl) throws public interface SignatureValidatorListener { - void artifactSignatureCorrect(MavenArtifact artifact, PGPPublicKey publicKey); + void artifactSignatureCorrect(ValidationResource artifact, PGPPublicKey publicKey); - void artifactSignatureInvalid(MavenArtifact artifact, PGPPublicKey publicKey); + void artifactSignatureInvalid(ValidationResource artifact, PGPPublicKey publicKey); } } diff --git a/gpg-validator/src/test/java/org/wildfly/channel/gpg/GpgSignatureValidatorTest.java b/gpg-validator/src/test/java/org/wildfly/channel/gpg/GpgSignatureValidatorTest.java index b4f8f186..5a8bac76 100644 --- a/gpg-validator/src/test/java/org/wildfly/channel/gpg/GpgSignatureValidatorTest.java +++ b/gpg-validator/src/test/java/org/wildfly/channel/gpg/GpgSignatureValidatorTest.java @@ -40,14 +40,15 @@ import org.pgpainless.key.protection.UnprotectedKeysProtector; import org.pgpainless.key.util.RevocationAttributes; import org.wildfly.channel.ArtifactCoordinate; -import org.wildfly.channel.MavenArtifact; import org.wildfly.channel.spi.SignatureResult; import org.wildfly.channel.spi.SignatureValidator; +import org.wildfly.channel.spi.ValidationResource; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; @@ -69,7 +70,10 @@ public class GpgSignatureValidatorTest { private PGPSecretKeyRing pgpExpiredKeys; private TestKeystore keystore = new TestKeystore(); private GpgSignatureValidator validator; - private MavenArtifact anArtifact; + private ValidationResource.MavenResource anArtifact; + private InputStream artifactInputStream; + private File artifactFile; + private InputStream signatureInputStream; private File signatureFile; @BeforeEach @@ -85,18 +89,20 @@ public void setUp() throws Exception { keystore = new TestKeystore(); validator = new GpgSignatureValidator(keystore); - File artifactFile = tempDir.resolve("test-one.jar").toFile(); + this.artifactFile = tempDir.resolve("test-one.jar").toFile(); Files.writeString(artifactFile.toPath(), "test"); - anArtifact = new MavenArtifact("org.test", "test-one", "jar", null, "1.0.0", artifactFile); + this.artifactInputStream = new FileInputStream(artifactFile); + anArtifact = new ValidationResource.MavenResource("org.test", "test-one", "jar", null, "1.0.0"); - signatureFile = signFile(anArtifact.getFile(), pgpValidKeys); + this.signatureFile = signFile(artifactFile, pgpValidKeys); + this.signatureInputStream = new FileInputStream(signatureFile); } @Test public void validSignatureIsAccepted() throws Exception { keystore.using(pgpValidKeys); - Assertions.assertThat(validator.validateSignature(anArtifact, signatureFile, Collections.emptyList())) + Assertions.assertThat(validator.validateSignature(anArtifact, artifactInputStream, signatureInputStream, Collections.emptyList())) .hasFieldOrPropertyWithValue("result", SignatureResult.Result.OK); } @@ -104,11 +110,15 @@ public void validSignatureIsAccepted() throws Exception { public void invalidSignatureReturnsErrorStatus() throws Exception { keystore.using(pgpValidKeys); - final File signatureFile = signFile(anArtifact.getFile(), pgpAttackerKeys); + final File signatureFile = signFile(artifactFile, pgpAttackerKeys); - Assertions.assertThat(validator.validateSignature(anArtifact, signatureFile, Collections.emptyList())) - .hasFieldOrPropertyWithValue("result", SignatureResult.Result.NO_MATCHING_CERT) - .hasFieldOrPropertyWithValue("artifact", toCoord()); + Assertions.assertThat(validator.validateSignature(anArtifact, artifactInputStream, signatureInputStream, Collections.emptyList())) + .extracting( + SignatureResult::getResult, + SignatureResult::getResource) + .containsExactly( + SignatureResult.Result.NO_MATCHING_CERT, + anArtifact); } @Test @@ -121,12 +131,17 @@ public void expiredSignatureReturnsError() throws Exception { Thread.sleep(100); } - final File signatureFile = signFile(anArtifact.getFile(), pgpExpiredKeys); - - Assertions.assertThat(validator.validateSignature(anArtifact, signatureFile, Collections.emptyList())) - .hasFieldOrPropertyWithValue("result", SignatureResult.Result.EXPIRED) - .hasFieldOrPropertyWithValue("artifact", toCoord()) - .hasFieldOrPropertyWithValue("keyId", toHex(pgpExpiredKeys.getPublicKey().getKeyID())); + final File signatureFile = signFile(artifactFile, pgpExpiredKeys); + + Assertions.assertThat(validator.validateSignature(anArtifact, artifactInputStream, new FileInputStream(signatureFile), Collections.emptyList())) + .extracting( + SignatureResult::getResult, + SignatureResult::getResource, + SignatureResult::getKeyId) + .containsExactly( + SignatureResult.Result.EXPIRED, + anArtifact, + toHex(pgpExpiredKeys.getPublicKey().getKeyID())); } @Test @@ -141,11 +156,17 @@ public void revokedSignatureReturnsError() throws Exception { .done(); keystore.using(pgpExpiredKeys); - Assertions.assertThat(validator.validateSignature(anArtifact, signatureFile, Collections.emptyList())) - .hasFieldOrPropertyWithValue("result", SignatureResult.Result.REVOKED) - .hasFieldOrPropertyWithValue("artifact", toCoord()) - .hasFieldOrPropertyWithValue("keyId", toHex(pgpValidKeys.getPublicKey().getKeyID())) - .hasFieldOrPropertyWithValue("message", "The key is revoked"); + Assertions.assertThat(validator.validateSignature(anArtifact, artifactInputStream, signatureInputStream, Collections.emptyList())) + .extracting( + SignatureResult::getResult, + SignatureResult::getResource, + SignatureResult::getKeyId, + SignatureResult::getMessage) + .containsExactly( + SignatureResult.Result.REVOKED, + anArtifact, + toHex(pgpValidKeys.getPublicKey().getKeyID()), + "The key is revoked"); } @Test @@ -155,8 +176,9 @@ public void downloadsSignatureIfUrlIsProvided() throws Exception { // export the public certificate final File publicCertFile = exportPublicCertificate(pgpValidKeys); - Assertions.assertThat(validator.validateSignature(anArtifact, signatureFile, List.of(publicCertFile.toURI().toString()))) - .hasFieldOrPropertyWithValue("result", SignatureResult.Result.OK); + Assertions.assertThat(validator.validateSignature(anArtifact, artifactInputStream, signatureInputStream, List.of(publicCertFile.toURI().toString()))) + .extracting(SignatureResult::getResult) + .isEqualTo(SignatureResult.Result.OK); Assertions.assertThat(keystore.getKeys().keySet()) .containsOnly(toHex(pgpValidKeys.getPublicKey().getKeyID())); @@ -171,7 +193,7 @@ public void failedSignatureDownloadThrowsException() throws Exception { Files.writeString(publicCertFile.toPath(), "I'm not a certificate"); final String certUrl = publicCertFile.toURI().toString(); - Assertions.assertThatThrownBy(()->validator.validateSignature(anArtifact, signatureFile, List.of(certUrl))) + Assertions.assertThatThrownBy(()->validator.validateSignature(anArtifact, artifactInputStream, signatureInputStream, List.of(certUrl))) .isInstanceOf(SignatureValidator.SignatureException.class) .hasMessageContainingAll("Unable to parse the certificate downloaded from " + certUrl); } @@ -180,15 +202,22 @@ public void failedSignatureDownloadThrowsException() throws Exception { public void invalidSignatureDownloadedReturnsError() throws Exception { keystore.using(Collections.emptyList()); - final File signatureFile = signFile(anArtifact.getFile(), pgpAttackerKeys); + final File signatureFile = signFile(artifactFile, pgpAttackerKeys); // export the public certificate final File publicCertFile = exportPublicCertificate(pgpValidKeys); - Assertions.assertThat(validator.validateSignature(anArtifact, signatureFile, List.of(publicCertFile.toURI().toString()))) + Assertions.assertThat(validator.validateSignature(anArtifact, artifactInputStream, + new FileInputStream(signatureFile), List.of(publicCertFile.toURI().toString()))) .hasFieldOrPropertyWithValue("result", SignatureResult.Result.NO_MATCHING_CERT) - .hasFieldOrPropertyWithValue("artifact", toCoord()) - .hasFieldOrPropertyWithValue("keyId", toHex(pgpAttackerKeys.getPublicKey().getKeyID())); + .extracting( + SignatureResult::getResult, + SignatureResult::getResource, + SignatureResult::getKeyId) + .containsExactly( + SignatureResult.Result.NO_MATCHING_CERT, + anArtifact, + toHex(pgpAttackerKeys.getPublicKey().getKeyID())); // no certificates should have been imported Assertions.assertThat(keystore.getKeys().keySet()) @@ -203,10 +232,16 @@ public void keystoreRejectingCertificateReturnsError() throws Exception { final File publicCertFile = exportPublicCertificate(pgpValidKeys); - Assertions.assertThat(validator.validateSignature(anArtifact, signatureFile, List.of(publicCertFile.toURI().toString()))) - .hasFieldOrPropertyWithValue("result", SignatureResult.Result.NO_MATCHING_CERT) - .hasFieldOrPropertyWithValue("artifact", toCoord()) - .hasFieldOrPropertyWithValue("keyId", toHex(pgpValidKeys.getPublicKey().getKeyID())); + Assertions.assertThat(validator.validateSignature(anArtifact, artifactInputStream, signatureInputStream, + List.of(publicCertFile.toURI().toString()))) + .extracting( + SignatureResult::getResult, + SignatureResult::getResource, + SignatureResult::getKeyId) + .containsExactly( + SignatureResult.Result.NO_MATCHING_CERT, + anArtifact, + toHex(pgpValidKeys.getPublicKey().getKeyID())); } private ArtifactCoordinate toCoord() { From b33d902e5efcd069f4b4f1763477c3b112d7b734 Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Thu, 1 Aug 2024 14:45:56 +0200 Subject: [PATCH 06/10] Add tests for ChannelImpl and ChannelSession --- .../java/org/wildfly/channel/ChannelImpl.java | 11 +- .../org/wildfly/channel/ChannelSession.java | 1 + .../channel/ChannelSessionInitTestCase.java | 86 ++++++++++ .../channel/ChannelSessionTestCase.java | 3 + ...essionWithSignatureValidationTestCase.java | 159 ++++++++++++++++++ .../channel/ChannelWithBlocklistTestCase.java | 5 +- gpg-validator/pom.xml | 2 +- 7 files changed, 258 insertions(+), 9 deletions(-) create mode 100644 core/src/test/java/org/wildfly/channel/ChannelSessionWithSignatureValidationTestCase.java diff --git a/core/src/main/java/org/wildfly/channel/ChannelImpl.java b/core/src/main/java/org/wildfly/channel/ChannelImpl.java index c8d9bd53..5b7c4a89 100644 --- a/core/src/main/java/org/wildfly/channel/ChannelImpl.java +++ b/core/src/main/java/org/wildfly/channel/ChannelImpl.java @@ -49,6 +49,7 @@ class ChannelImpl implements AutoCloseable { private static final Logger LOG = Logger.getLogger(ChannelImpl.class); + protected static final String SIGNATURE_FILE_SUFFIX = ".asc"; private final Channel channelDefinition; private Channel resolvedChannel; @@ -322,9 +323,9 @@ public List resolveChannelMetadata(List resolveArtifacts(List coordinate if (channelDefinition.isGpgCheck()) { try { final List signatures = resolver.resolveArtifacts(coordinates.stream() - .map(c->new ArtifactCoordinate(c.getGroupId(), c.getArtifactId(), c.getExtension() + ".asc", + .map(c->new ArtifactCoordinate(c.getGroupId(), c.getArtifactId(), c.getExtension() + SIGNATURE_FILE_SUFFIX, c.getClassifier(), c.getVersion())) .collect(Collectors.toList())); for (int i = 0; i < resolvedArtifacts.size(); i++) { diff --git a/core/src/main/java/org/wildfly/channel/ChannelSession.java b/core/src/main/java/org/wildfly/channel/ChannelSession.java index 3d006527..17351a4d 100644 --- a/core/src/main/java/org/wildfly/channel/ChannelSession.java +++ b/core/src/main/java/org/wildfly/channel/ChannelSession.java @@ -81,6 +81,7 @@ public ChannelSession(List channelDefinitions, MavenVersionsResolver.Fa int versionResolutionParallelism, SignatureValidator signatureValidator) { requireNonNull(channelDefinitions); requireNonNull(factory); + requireNonNull(signatureValidator); final Set repositories = channelDefinitions.stream().flatMap(c -> c.getRepositories().stream()).collect(Collectors.toSet()); this.combinedResolver = factory.create(repositories); diff --git a/core/src/test/java/org/wildfly/channel/ChannelSessionInitTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelSessionInitTestCase.java index 10514e03..cf0ef11d 100644 --- a/core/src/test/java/org/wildfly/channel/ChannelSessionInitTestCase.java +++ b/core/src/test/java/org/wildfly/channel/ChannelSessionInitTestCase.java @@ -21,9 +21,13 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.wildfly.channel.spi.MavenVersionsResolver; +import org.wildfly.channel.spi.SignatureResult; +import org.wildfly.channel.spi.SignatureValidator; +import org.wildfly.channel.spi.ValidationResource; import java.io.File; import java.io.IOException; +import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -38,6 +42,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.wildfly.channel.ChannelImpl.SIGNATURE_FILE_SUFFIX; public class ChannelSessionInitTestCase { @TempDir @@ -422,6 +427,87 @@ public void getVersionOfResolvedBlocklist() throws Exception { } } + @Test + public void mavenManifestWithoutSignatureCausesError() throws Exception { + MavenVersionsResolver.Factory factory = mock(MavenVersionsResolver.Factory.class); + MavenVersionsResolver resolver = mock(MavenVersionsResolver.class); + when(factory.create(any())).thenReturn(resolver); + + final ChannelManifest baseManifest = new ManifestBuilder() + .setId("manifest-one") + .build(); + mockManifest(resolver, baseManifest, "test.channels:base-manifest:1.0.0"); + + // two channels providing base- and required- manifests + List channels = List.of(new Channel.Builder() + .setName("channel one") + .addRepository("test", "test") + .setManifestCoordinate("test.channels", "base-manifest", "1.0.0") + .setGpgCheck(true) + .build() + ); + + when(resolver.resolveArtifact("test.channels", "base-manifest", + ChannelManifest.EXTENSION + SIGNATURE_FILE_SUFFIX, ChannelManifest.CLASSIFIER, "1.0.0")) + .thenThrow(ArtifactTransferException.class); + assertThrows(SignatureValidator.SignatureException.class, () -> new ChannelSession(channels, factory)); + } + + @Test + public void urlManifestWithoutSignatureCausesError() throws Exception { + MavenVersionsResolver.Factory factory = mock(MavenVersionsResolver.Factory.class); + MavenVersionsResolver resolver = mock(MavenVersionsResolver.class); + when(factory.create(any())).thenReturn(resolver); + + final ChannelManifest baseManifest = new ManifestBuilder() + .setId("manifest-one") + .build(); + final Path manifestFile = tempDir.resolve("test-manifest.yaml"); + Files.writeString(manifestFile, ChannelManifestMapper.toYaml(baseManifest)); + + // two channels providing base- and required- manifests + List channels = List.of(new Channel.Builder() + .setName("channel one") + .addRepository("test", "test") + .setManifestCoordinate(new ChannelManifestCoordinate(manifestFile.toUri().toURL())) + .setGpgCheck(true) + .build() + ); + + when(resolver.resolveArtifact("test.channels", "base-manifest", + ChannelManifest.EXTENSION + SIGNATURE_FILE_SUFFIX, ChannelManifest.CLASSIFIER, "1.0.0")) + .thenThrow(ArtifactTransferException.class); + assertThrows(InvalidChannelMetadataException.class, () -> new ChannelSession(channels, factory)); + } + + @Test + public void invalidSignatureCausesError() throws Exception { + MavenVersionsResolver.Factory factory = mock(MavenVersionsResolver.Factory.class); + MavenVersionsResolver resolver = mock(MavenVersionsResolver.class); + final SignatureValidator signatureValidator = mock(SignatureValidator.class); + when(factory.create(any())).thenReturn(resolver); + + final ChannelManifest baseManifest = new ManifestBuilder() + .setId("manifest-one") + .build(); + mockManifest(resolver, baseManifest, "test.channels:base-manifest:1.0.0"); + + // two channels providing base- and required- manifests + List channels = List.of(new Channel.Builder() + .setName("channel one") + .addRepository("test", "test") + .setManifestCoordinate("test.channels", "base-manifest", "1.0.0") + .setGpgCheck(true) + .build() + ); + + when(resolver.resolveArtifact("test.channels", "base-manifest", + ChannelManifest.EXTENSION + SIGNATURE_FILE_SUFFIX, ChannelManifest.CLASSIFIER, "1.0.0")) + .thenReturn(tempDir.resolve("test-manifest.yaml.asc").toFile()); + when(signatureValidator.validateSignature(any(), any(), any(), any())).thenReturn(SignatureResult.invalid(mock(ValidationResource.class))); + assertThrows(SignatureValidator.SignatureException.class, () -> new ChannelSession(channels, factory)); + } + private void mockManifest(MavenVersionsResolver resolver, ChannelManifest manifest, String gav) throws IOException { mockManifest(resolver, ChannelManifestMapper.toYaml(manifest), gav); } diff --git a/core/src/test/java/org/wildfly/channel/ChannelSessionTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelSessionTestCase.java index ee45f64d..4914ac22 100644 --- a/core/src/test/java/org/wildfly/channel/ChannelSessionTestCase.java +++ b/core/src/test/java/org/wildfly/channel/ChannelSessionTestCase.java @@ -51,6 +51,9 @@ import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; import org.wildfly.channel.spi.MavenVersionsResolver; +import org.wildfly.channel.spi.SignatureResult; +import org.wildfly.channel.spi.SignatureValidator; +import org.wildfly.channel.spi.ValidationResource; public class ChannelSessionTestCase { diff --git a/core/src/test/java/org/wildfly/channel/ChannelSessionWithSignatureValidationTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelSessionWithSignatureValidationTestCase.java new file mode 100644 index 00000000..aceec384 --- /dev/null +++ b/core/src/test/java/org/wildfly/channel/ChannelSessionWithSignatureValidationTestCase.java @@ -0,0 +1,159 @@ +package org.wildfly.channel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.wildfly.channel.ChannelImpl.SIGNATURE_FILE_SUFFIX; +import static org.wildfly.channel.ChannelManifestMapper.CURRENT_SCHEMA_VERSION; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.wildfly.channel.spi.MavenVersionsResolver; +import org.wildfly.channel.spi.SignatureResult; +import org.wildfly.channel.spi.SignatureValidator; +import org.wildfly.channel.spi.ValidationResource; + +public class ChannelSessionWithSignatureValidationTestCase { + + private static final ValidationResource.MavenResource ARTIFACT = new ValidationResource.MavenResource( + "org.wildfly", "wildfly-ee-galleon-pack", "zip", null, "25.0.1.Final"); + + private static final ValidationResource.MavenResource MANIFEST = new ValidationResource.MavenResource( + "org.channels", "test-manifest", ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER, "1.0.0"); + + @TempDir + private Path tempDir; + private MavenVersionsResolver resolver; + private SignatureValidator signatureValidator; + private MavenVersionsResolver.Factory factory; + private File resolvedArtifactFile; + private List channels; + private File signatureFile; + + @BeforeEach + public void setUp() throws Exception { + factory = mock(MavenVersionsResolver.Factory.class); + resolver = mock(MavenVersionsResolver.class); + signatureValidator = mock(SignatureValidator.class); + when(factory.create(any())).thenReturn(resolver); + + // create a manfiest with a versionPattern to test signature od the latest resolved version is downloaded + final String manifest = "schemaVersion: " + CURRENT_SCHEMA_VERSION + "\n" + + "streams:\n" + + " - groupId: org.wildfly\n" + + " artifactId: '*'\n" + + " versionPattern: '25\\.\\d+\\.\\d+.Final'"; + // create a channel requiring a gpg check + channels = List.of(new Channel.Builder() + .setName("channel-0") + .setGpgCheck(true) + .setManifestCoordinate(MANIFEST.groupId, MANIFEST.artifactId, MANIFEST.version) + .build()); + + // the resolved files need to exist otherwise we can't create streams from them + resolvedArtifactFile = tempDir.resolve("test-artifact").toFile(); + Files.createFile(resolvedArtifactFile.toPath()); + signatureFile = tempDir.resolve("test-signature.asc").toFile(); + Files.createFile(signatureFile.toPath()); + + + when(resolver.getAllVersions(ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension, ARTIFACT.classifier)) + .thenReturn(new HashSet<>(Arrays.asList("25.0.0.Final", ARTIFACT.version))); + when(resolver.resolveArtifact(ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension, ARTIFACT.classifier, ARTIFACT.version)) + .thenReturn(resolvedArtifactFile); + + + Path manifestFile = Files.writeString(tempDir.resolve("manifest.yaml"), manifest); + when(resolver.resolveArtifact(MANIFEST.groupId, MANIFEST.artifactId, MANIFEST.extension, MANIFEST.classifier, MANIFEST.version)) + .thenReturn(manifestFile.toFile()); + when(resolver.resolveArtifact(MANIFEST.groupId, MANIFEST.artifactId, + MANIFEST.extension + SIGNATURE_FILE_SUFFIX, MANIFEST.classifier, MANIFEST.version)) + .thenReturn(signatureFile); + } + + @Test + public void artifactWithCorrectSignatureIsValidated() throws Exception { + // return signature when resolving it from Maven repository + when(resolver.resolveArtifact(ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension + SIGNATURE_FILE_SUFFIX, + ARTIFACT.classifier, ARTIFACT.version)) + .thenReturn(signatureFile); + // accept all the validation requests + when(signatureValidator.validateSignature(any(), + any(), any(), any())).thenReturn(SignatureResult.ok()); + + + try (ChannelSession session = new ChannelSession(channels, factory, signatureValidator)) { + MavenArtifact artifact = session.resolveMavenArtifact( + ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension, ARTIFACT.classifier, null); + assertNotNull(artifact); + + assertEquals(ARTIFACT.groupId, artifact.getGroupId()); + assertEquals(ARTIFACT.artifactId, artifact.getArtifactId()); + assertEquals(ARTIFACT.extension, artifact.getExtension()); + assertNull(artifact.getClassifier()); + assertEquals(ARTIFACT.version, artifact.getVersion()); + assertEquals(resolvedArtifactFile, artifact.getFile()); + assertEquals("channel-0", artifact.getChannelName().get()); + } + + // validateSignature should have been called for the manifest and the artifact + verify(signatureValidator, times(2)).validateSignature(any(), any(), any(), any()); + } + + @Test + public void artifactWithoutSignatureIsRejected() throws Exception { + // simulate situation where the signature file does not exist in the repository + when(resolver.resolveArtifact(ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension + SIGNATURE_FILE_SUFFIX, + ARTIFACT.classifier, ARTIFACT.version)) + .thenThrow(ArtifactTransferException.class); + // accept all the validation requests + when(signatureValidator.validateSignature(any(), + any(), any(), any())).thenReturn(SignatureResult.ok()); + + try (ChannelSession session = new ChannelSession(channels, factory, signatureValidator)) { + assertThrows(SignatureValidator.SignatureException.class, () -> session.resolveMavenArtifact( + ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension, ARTIFACT.classifier, null)); + } + + // validateSignature should have been called for the manifest only + verify(signatureValidator, times(1)).validateSignature(any(), any(), any(), any()); + } + + @Test + public void failedSignatureValidationThrowsException() throws Exception { + // return signature when resolving it from Maven repository + when(resolver.resolveArtifact(ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension + SIGNATURE_FILE_SUFFIX, + ARTIFACT.classifier, ARTIFACT.version)) + .thenReturn(signatureFile); + // simulate a valid signature of the channel manifest, and invalid signature of the artifact + when(signatureValidator.validateSignature(eq(new ValidationResource.MavenResource( + MANIFEST.groupId, MANIFEST.artifactId, MANIFEST.extension, MANIFEST.classifier, MANIFEST.version)), + any(), any(), any())).thenReturn(SignatureResult.ok()); + when(signatureValidator.validateSignature(eq(ARTIFACT), + any(), any(), any())).thenReturn(SignatureResult.invalid(ARTIFACT)); + + + try (ChannelSession session = new ChannelSession(channels, factory, signatureValidator)) { + assertThrows(SignatureValidator.SignatureException.class, () -> session.resolveMavenArtifact("org.wildfly", + "wildfly-ee-galleon-pack", "zip", null, "25.0.0.Final")); + } + + // validateSignature should have been called for the manifest and the artifact + verify(signatureValidator, times(2)).validateSignature(any(), any(), any(), any()); + } +} diff --git a/core/src/test/java/org/wildfly/channel/ChannelWithBlocklistTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelWithBlocklistTestCase.java index 41b40592..6e0fc770 100644 --- a/core/src/test/java/org/wildfly/channel/ChannelWithBlocklistTestCase.java +++ b/core/src/test/java/org/wildfly/channel/ChannelWithBlocklistTestCase.java @@ -331,9 +331,8 @@ public void testResolveLatestMavenArtifactThrowUnresolvedMavenArtifactException( verify(resolver, times(2)).close(); } - private void mockBlocklist(MavenVersionsResolver resolver, String blocklistFileLocation, String groupId, String artifactId, String version) throws URISyntaxException { -// when(resolver.resolveChannelMetadata(List.of(new BlocklistCoordinate("org.wildfly", "wildfly-blocklist")))) -// .thenReturn(List.of(this.getClass().getClassLoader().getResource("channels/test-blocklist.yaml"))); + private void mockBlocklist(MavenVersionsResolver resolver, String blocklistFileLocation, + String groupId, String artifactId, String version) throws URISyntaxException { if (version == null) { when(resolver.getAllVersions(groupId, artifactId, BlocklistCoordinate.EXTENSION, diff --git a/gpg-validator/pom.xml b/gpg-validator/pom.xml index 38760cef..39ab2bc3 100644 --- a/gpg-validator/pom.xml +++ b/gpg-validator/pom.xml @@ -3,7 +3,7 @@ channel-parent org.wildfly.channel - 1.1.1.Final-SNAPSHOT + 1.2.1.Final-SNAPSHOT 4.0.0 From 02c5aa04e386ee87fabd9d99d3bf42b9a2c75910 Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Fri, 2 Aug 2024 12:41:38 +0200 Subject: [PATCH 07/10] Update Javadocs --- .../java/org/wildfly/channel/ChannelImpl.java | 11 +- .../org/wildfly/channel/ChannelSession.java | 11 +- ...nResource.java => ArtifactIdentifier.java} | 32 ++- .../wildfly/channel/spi/SignatureResult.java | 28 ++- .../channel/spi/SignatureValidator.java | 16 +- .../channel/ChannelSessionInitTestCase.java | 5 +- .../channel/ChannelSessionTestCase.java | 3 - ...essionWithSignatureValidationTestCase.java | 24 ++- .../org/wildfly/channel/gpg/GpgKeystore.java | 19 ++ .../channel/gpg/GpgSignatureValidator.java | 186 ++++++++++++------ .../gpg/GpgSignatureValidatorListener.java | 42 ++++ .../org/wildfly/channel/gpg/Keyserver.java | 11 ++ .../gpg/GpgSignatureValidatorTest.java | 6 +- 13 files changed, 302 insertions(+), 92 deletions(-) rename core/src/main/java/org/wildfly/channel/spi/{ValidationResource.java => ArtifactIdentifier.java} (62%) create mode 100644 gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidatorListener.java diff --git a/core/src/main/java/org/wildfly/channel/ChannelImpl.java b/core/src/main/java/org/wildfly/channel/ChannelImpl.java index 5b7c4a89..0f6fcb24 100644 --- a/core/src/main/java/org/wildfly/channel/ChannelImpl.java +++ b/core/src/main/java/org/wildfly/channel/ChannelImpl.java @@ -40,7 +40,7 @@ import org.wildfly.channel.spi.MavenVersionsResolver; import org.wildfly.channel.spi.SignatureResult; import org.wildfly.channel.spi.SignatureValidator; -import org.wildfly.channel.spi.ValidationResource; +import org.wildfly.channel.spi.ArtifactIdentifier; import org.wildfly.channel.version.VersionMatcher; /** @@ -82,6 +82,7 @@ public ChannelImpl(Channel channelDefinition) { * * @param factory * @param channels + * @param signatureValidator - the validator used to check the signatures of resolved artifacts * @throws UnresolvedRequiredManifestException - if a required manifest cannot be resolved either via maven coordinates or in the list of channels * @throws CyclicDependencyException - if the required manifests form a cyclic dependency */ @@ -492,7 +493,7 @@ ResolveArtifactResult resolveArtifact(String groupId, String artifactId, String private void validateGpgSignature(String groupId, String artifactId, String extension, String classifier, String version, File artifact) { - final ValidationResource mavenArtifact = new ValidationResource.MavenResource(groupId, artifactId, extension, + final ArtifactIdentifier mavenArtifact = new ArtifactIdentifier.MavenResource(groupId, artifactId, extension, classifier, version); try { final File signature = resolver.resolveArtifact(groupId, artifactId, extension + SIGNATURE_FILE_SUFFIX, @@ -511,7 +512,7 @@ mavenArtifact, new FileInputStream(artifact), new FileInputStream(signature), private void validateGpgSignature(URL artifactFile, URL signature) throws IOException { final SignatureResult signatureResult = signatureValidator.validateSignature( - new ValidationResource.UrlResource(artifactFile), + new ArtifactIdentifier.UrlResource(artifactFile), artifactFile.openStream(), signature.openStream(), channelDefinition.getGpgUrls() ); @@ -533,7 +534,7 @@ List resolveArtifacts(List coordinate for (int i = 0; i < resolvedArtifacts.size(); i++) { final File artifact = resolvedArtifacts.get(i); final ArtifactCoordinate c = coordinates.get(i); - final ValidationResource.MavenResource mavenArtifact = new ValidationResource.MavenResource(c.getGroupId(), c.getArtifactId(), + final ArtifactIdentifier.MavenResource mavenArtifact = new ArtifactIdentifier.MavenResource(c.getGroupId(), c.getArtifactId(), c.getExtension(), c.getClassifier(), c.getVersion()); final File signature = signatures.get(i); try { @@ -549,7 +550,7 @@ List resolveArtifacts(List coordinate } } } catch (ArtifactTransferException e) { - final ValidationResource.MavenResource artifact = new ValidationResource.MavenResource(e.getUnresolvedArtifacts().stream().findFirst().get()); + final ArtifactIdentifier.MavenResource artifact = new ArtifactIdentifier.MavenResource(e.getUnresolvedArtifacts().stream().findFirst().get()); throw new SignatureValidator.SignatureException(String.format("Unable to find required signature for %s:%s:%s", artifact.getGroupId(), artifact.getArtifactId(), artifact.getVersion()), SignatureResult.noSignature(artifact)); diff --git a/core/src/main/java/org/wildfly/channel/ChannelSession.java b/core/src/main/java/org/wildfly/channel/ChannelSession.java index 17351a4d..5e37c659 100644 --- a/core/src/main/java/org/wildfly/channel/ChannelSession.java +++ b/core/src/main/java/org/wildfly/channel/ChannelSession.java @@ -53,7 +53,7 @@ public class ChannelSession implements AutoCloseable { private final int versionResolutionParallelism; /** - * Create a ChannelSession. + * Create a ChannelSession with a default rejecting signature validator. * * @param channelDefinitions the list of channels to resolve Maven artifact * @param factory Factory to create {@code MavenVersionsResolver} that are performing the actual Maven resolution. @@ -64,6 +64,15 @@ public ChannelSession(List channelDefinitions, MavenVersionsResolver.Fa this(channelDefinitions, factory, DEFAULT_SPLIT_ARTIFACT_PARALLELISM, SignatureValidator.REJECTING_VALIDATOR); } + /** + * Create a ChannelSession with a default rejecting signature validator. + * + * @param channelDefinitions the list of channels to resolve Maven artifact + * @param factory Factory to create {@code MavenVersionsResolver} that are performing the actual Maven resolution. + * @param signatureValidator Validator to verify signatures of downloaded artifacts + * @throws UnresolvedRequiredManifestException - if a required manifest cannot be resolved either via maven coordinates or in the list of channels + * @throws CyclicDependencyException - if the required manifests form a cyclic dependency + */ public ChannelSession(List channelDefinitions, MavenVersionsResolver.Factory factory, SignatureValidator signatureValidator) { this(channelDefinitions, factory, DEFAULT_SPLIT_ARTIFACT_PARALLELISM, signatureValidator); } diff --git a/core/src/main/java/org/wildfly/channel/spi/ValidationResource.java b/core/src/main/java/org/wildfly/channel/spi/ArtifactIdentifier.java similarity index 62% rename from core/src/main/java/org/wildfly/channel/spi/ValidationResource.java rename to core/src/main/java/org/wildfly/channel/spi/ArtifactIdentifier.java index 8cd7164d..6942e487 100644 --- a/core/src/main/java/org/wildfly/channel/spi/ValidationResource.java +++ b/core/src/main/java/org/wildfly/channel/spi/ArtifactIdentifier.java @@ -20,10 +20,13 @@ import org.wildfly.channel.ArtifactCoordinate; -public interface ValidationResource { +/** + * An identifier of an artifact being validated. It can be either a Maven coordinate or an URL. + */ +public interface ArtifactIdentifier { - class UrlResource implements ValidationResource { - private URL resourceUrl; + class UrlResource implements ArtifactIdentifier { + private final URL resourceUrl; public UrlResource(URL resourceUrl) { this.resourceUrl = resourceUrl; @@ -32,9 +35,14 @@ public UrlResource(URL resourceUrl) { public URL getResourceUrl() { return resourceUrl; } + + @Override + public String getDescription() { + return resourceUrl.toExternalForm(); + } } - class MavenResource extends ArtifactCoordinate implements ValidationResource { + class MavenResource extends ArtifactCoordinate implements ArtifactIdentifier { public MavenResource(String groupId, String artifactId, String extension, String classifier, String version) { super(groupId, artifactId, extension, classifier, version); @@ -47,8 +55,24 @@ public MavenResource(ArtifactCoordinate artifactCoordinate) { artifactCoordinate.getClassifier(), artifactCoordinate.getVersion()); } + + @Override + public String getDescription() { + StringBuilder sb = new StringBuilder(); + sb.append(groupId).append(":").append(artifactId).append(":"); + if (classifier != null && !classifier.isEmpty()) { + sb.append(classifier).append(":"); + } + if (extension != null && !extension.isEmpty()) { + sb.append(extension).append(":"); + } + sb.append(version); + return sb.toString(); + } } + String getDescription(); + default boolean isMavenResource() { return this instanceof MavenResource; } diff --git a/core/src/main/java/org/wildfly/channel/spi/SignatureResult.java b/core/src/main/java/org/wildfly/channel/spi/SignatureResult.java index 4619c1c6..f02eb8e5 100644 --- a/core/src/main/java/org/wildfly/channel/spi/SignatureResult.java +++ b/core/src/main/java/org/wildfly/channel/spi/SignatureResult.java @@ -16,29 +16,41 @@ */ package org.wildfly.channel.spi; +/** + * Represents a result of artifact verification + */ public class SignatureResult { - private ValidationResource resource; + /** + * Identifier of the artifact that was being verified. + */ + private ArtifactIdentifier resource; + /** + * Identifier of the certificate used to verify the artifact. + */ private String keyId; + /** + * Optional message with details of validation. + */ private String message; - public static SignatureResult noMatchingCertificate(ValidationResource resource, String keyID) { + public static SignatureResult noMatchingCertificate(ArtifactIdentifier resource, String keyID) { return new SignatureResult(Result.NO_MATCHING_CERT, resource, keyID, null); } - public static SignatureResult revoked(ValidationResource resource, String keyID, String revocationReason) { + public static SignatureResult revoked(ArtifactIdentifier resource, String keyID, String revocationReason) { return new SignatureResult(Result.REVOKED, resource, keyID, revocationReason); } - public static SignatureResult expired(ValidationResource resource, String keyID) { + public static SignatureResult expired(ArtifactIdentifier resource, String keyID) { return new SignatureResult(Result.EXPIRED, resource, keyID, null); } - public static SignatureResult noSignature(ValidationResource resource) { + public static SignatureResult noSignature(ArtifactIdentifier resource) { return new SignatureResult(Result.NO_SIGNATURE, resource, null, null); } - public static SignatureResult invalid(ValidationResource resource) { + public static SignatureResult invalid(ArtifactIdentifier resource) { return new SignatureResult(Result.INVALID, resource, null, null); } @@ -48,7 +60,7 @@ public static SignatureResult ok() { return new SignatureResult(Result.OK, null, null, null); } - private SignatureResult(Result result, ValidationResource resource, String keyID, String message) { + private SignatureResult(Result result, ArtifactIdentifier resource, String keyID, String message) { this.result = result; this.resource = resource; this.keyId = keyID; @@ -59,7 +71,7 @@ public Result getResult() { return result; } - public ValidationResource getResource() { + public ArtifactIdentifier getResource() { return resource; } diff --git a/core/src/main/java/org/wildfly/channel/spi/SignatureValidator.java b/core/src/main/java/org/wildfly/channel/spi/SignatureValidator.java index adc42bdb..4a1da13f 100644 --- a/core/src/main/java/org/wildfly/channel/spi/SignatureValidator.java +++ b/core/src/main/java/org/wildfly/channel/spi/SignatureValidator.java @@ -23,21 +23,29 @@ * Called to validate detached signatures of artifacts resolved in the channel */ public interface SignatureValidator { + /** + * A default validator, rejecting all artifacts + */ SignatureValidator REJECTING_VALIDATOR = (artifactSource, artifactStream, signatureStream, gpgUrls) -> { throw new SignatureException("Not implemented", SignatureResult.noSignature(artifactSource)); }; /** - * validates a signature of {@code artifact}. The locally downloaded {@code signature} has to be an armour encoded GPG signature. + * validates a signature of an artifact. The locally downloaded {@code signature} has to be an armour encoded GPG signature. * - * @param artifact - {@code MavenArtifact} to validate. Includes a full GAV and the local artifact file. - * @param signature - local file containing armour encoded detached GPG signature for the {@code artifact}. + * @param artifactId - an identifier of the resource to be validated. + * @param artifactStream - an {@code InputStream} of the artifact to be verified. + * @param signatureStream - an {@code InputStream} of the armour encoded detached GPG signature for the artifact. * @param gpgUrls - URLs of the keys defined in the channel. Empty collection if channel does not define any signatures. * @return {@link SignatureResult} with the result of validation * @throws SignatureException - if an unexpected error occurred when handling the keys. */ - SignatureResult validateSignature(ValidationResource artifactSource, InputStream artifactStream, InputStream signatureStream, List gpgUrls) throws SignatureException; + SignatureResult validateSignature(ArtifactIdentifier artifactId, InputStream artifactStream, + InputStream signatureStream, List gpgUrls) throws SignatureException; + /** + * An exception signifying issue with an artifact signature validation. + */ class SignatureException extends RuntimeException { private final SignatureResult signatureResult; diff --git a/core/src/test/java/org/wildfly/channel/ChannelSessionInitTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelSessionInitTestCase.java index cf0ef11d..8737c1e0 100644 --- a/core/src/test/java/org/wildfly/channel/ChannelSessionInitTestCase.java +++ b/core/src/test/java/org/wildfly/channel/ChannelSessionInitTestCase.java @@ -23,11 +23,10 @@ import org.wildfly.channel.spi.MavenVersionsResolver; import org.wildfly.channel.spi.SignatureResult; import org.wildfly.channel.spi.SignatureValidator; -import org.wildfly.channel.spi.ValidationResource; +import org.wildfly.channel.spi.ArtifactIdentifier; import java.io.File; import java.io.IOException; -import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -504,7 +503,7 @@ public void invalidSignatureCausesError() throws Exception { when(resolver.resolveArtifact("test.channels", "base-manifest", ChannelManifest.EXTENSION + SIGNATURE_FILE_SUFFIX, ChannelManifest.CLASSIFIER, "1.0.0")) .thenReturn(tempDir.resolve("test-manifest.yaml.asc").toFile()); - when(signatureValidator.validateSignature(any(), any(), any(), any())).thenReturn(SignatureResult.invalid(mock(ValidationResource.class))); + when(signatureValidator.validateSignature(any(), any(), any(), any())).thenReturn(SignatureResult.invalid(mock(ArtifactIdentifier.class))); assertThrows(SignatureValidator.SignatureException.class, () -> new ChannelSession(channels, factory)); } diff --git a/core/src/test/java/org/wildfly/channel/ChannelSessionTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelSessionTestCase.java index 4914ac22..ee45f64d 100644 --- a/core/src/test/java/org/wildfly/channel/ChannelSessionTestCase.java +++ b/core/src/test/java/org/wildfly/channel/ChannelSessionTestCase.java @@ -51,9 +51,6 @@ import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; import org.wildfly.channel.spi.MavenVersionsResolver; -import org.wildfly.channel.spi.SignatureResult; -import org.wildfly.channel.spi.SignatureValidator; -import org.wildfly.channel.spi.ValidationResource; public class ChannelSessionTestCase { diff --git a/core/src/test/java/org/wildfly/channel/ChannelSessionWithSignatureValidationTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelSessionWithSignatureValidationTestCase.java index aceec384..1a47b080 100644 --- a/core/src/test/java/org/wildfly/channel/ChannelSessionWithSignatureValidationTestCase.java +++ b/core/src/test/java/org/wildfly/channel/ChannelSessionWithSignatureValidationTestCase.java @@ -1,3 +1,19 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.wildfly.channel; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -26,14 +42,14 @@ import org.wildfly.channel.spi.MavenVersionsResolver; import org.wildfly.channel.spi.SignatureResult; import org.wildfly.channel.spi.SignatureValidator; -import org.wildfly.channel.spi.ValidationResource; +import org.wildfly.channel.spi.ArtifactIdentifier; public class ChannelSessionWithSignatureValidationTestCase { - private static final ValidationResource.MavenResource ARTIFACT = new ValidationResource.MavenResource( + private static final ArtifactIdentifier.MavenResource ARTIFACT = new ArtifactIdentifier.MavenResource( "org.wildfly", "wildfly-ee-galleon-pack", "zip", null, "25.0.1.Final"); - private static final ValidationResource.MavenResource MANIFEST = new ValidationResource.MavenResource( + private static final ArtifactIdentifier.MavenResource MANIFEST = new ArtifactIdentifier.MavenResource( "org.channels", "test-manifest", ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER, "1.0.0"); @TempDir @@ -141,7 +157,7 @@ public void failedSignatureValidationThrowsException() throws Exception { ARTIFACT.classifier, ARTIFACT.version)) .thenReturn(signatureFile); // simulate a valid signature of the channel manifest, and invalid signature of the artifact - when(signatureValidator.validateSignature(eq(new ValidationResource.MavenResource( + when(signatureValidator.validateSignature(eq(new ArtifactIdentifier.MavenResource( MANIFEST.groupId, MANIFEST.artifactId, MANIFEST.extension, MANIFEST.classifier, MANIFEST.version)), any(), any(), any())).thenReturn(SignatureResult.ok()); when(signatureValidator.validateSignature(eq(ARTIFACT), diff --git a/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgKeystore.java b/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgKeystore.java index 76d2cb4b..8e1a9375 100644 --- a/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgKeystore.java +++ b/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgKeystore.java @@ -20,9 +20,28 @@ import java.util.List; +/** + * Local store of trusted public keys. + * + * Note: the keystore can reject a public key being added. In such case, the {@code GpgSignatureValidator} has to reject this key. + */ public interface GpgKeystore { + /** + * resolve a public key from the store. + * + * @param keyID - a HEX form of the key ID + * @return - the resolved public key or {@code null} if the key was not found + */ PGPPublicKey get(String keyID); + /** + * records the public keys in the store for future use. + * + * @param publicKey - list of trusted public keys + * @return true if the public keys have been added successfully + * false otherwise. + * @throws KeystoreOperationException if the keystore threw an error during the operation + */ boolean add(List publicKey) throws KeystoreOperationException; } diff --git a/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidator.java b/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidator.java index dd6c326f..889c7dfa 100644 --- a/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidator.java +++ b/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidator.java @@ -44,29 +44,30 @@ import org.bouncycastle.openpgp.operator.bc.BcPGPContentVerifierBuilderProvider; import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator; import org.jboss.logging.Logger; -import org.wildfly.channel.ArtifactCoordinate; -import org.wildfly.channel.MavenArtifact; import org.wildfly.channel.spi.SignatureResult; import org.wildfly.channel.spi.SignatureValidator; -import org.wildfly.channel.spi.ValidationResource; +import org.wildfly.channel.spi.ArtifactIdentifier; +/** + * Implementation of a GPG signature validator. + * + * Uses a combination of a local {@link GpgKeystore} and {@code GPG keyservers} to resolve certificates. + * To resolve a public key required by the artifact signature: + *
      + *
    • check if the key is present in the local GpgKeystore.
    • + *
    • check if one of the configured remote keystores contains the key.
    • + *
    • try to download the keys linked in the {@code gpgUrls}
    • + *
    + * + * The {@code GpgKeystore} acts as a source of trusted keys. A new key, resolved from either the keyserver or + * the gpgUrls is added to the GpgKeystore and used in subsequent checks. + */ public class GpgSignatureValidator implements SignatureValidator { private static final Logger LOG = Logger.getLogger(GpgSignatureValidator.class); private final GpgKeystore keystore; private final Keyserver keyserver; - private SignatureValidatorListener listener = new SignatureValidatorListener() { - - @Override - public void artifactSignatureCorrect(ValidationResource artifact, PGPPublicKey publicKey) { - // noop - } - - @Override - public void artifactSignatureInvalid(ValidationResource artifact, PGPPublicKey publicKey) { - // noop - } - }; + private GpgSignatureValidatorListener listener = new NoopListener(); public GpgSignatureValidator(GpgKeystore keystore) { this(keystore, new Keyserver(Collections.emptyList())); @@ -77,36 +78,48 @@ public GpgSignatureValidator(GpgKeystore keystore, Keyserver keyserver) { this.keyserver = keyserver; } - public void addListener(SignatureValidatorListener listener) { + public void addListener(GpgSignatureValidatorListener listener) { this.listener = listener; } @Override - public SignatureResult validateSignature(ValidationResource artifactSource, InputStream artifactStream, - InputStream signatureStream, List gpgUrls) throws SignatureException { - Objects.requireNonNull(artifactSource); + public SignatureResult validateSignature(ArtifactIdentifier artifactId, InputStream artifactStream, + InputStream signatureStream, List gpgUrls) throws SignatureException { + Objects.requireNonNull(artifactId); Objects.requireNonNull(artifactStream); Objects.requireNonNull(signatureStream); final PGPSignature pgpSignature; try { + if (LOG.isTraceEnabled()) { + LOG.trace("Reading the signature of artifact."); + } pgpSignature = readSignatureFile(signatureStream); } catch (IOException e) { throw new SignatureException("Could not find signature in provided signature file", e, - SignatureResult.noSignature(artifactSource)); + SignatureResult.noSignature(artifactId)); } if (pgpSignature == null) { LOG.error("Could not read the signature in provided signature file"); - return SignatureResult.noSignature(artifactSource); + return SignatureResult.noSignature(artifactId); } final String keyID = Long.toHexString(pgpSignature.getKeyID()).toUpperCase(Locale.ROOT); + if (LOG.isTraceEnabled()) { + LOG.tracef("The signature was created using public key %s.", keyID); + } final PGPPublicKey publicKey; if (keystore.get(keyID) != null) { + if (LOG.isTraceEnabled()) { + LOG.tracef("Using a public key %s was found in the local keystore.", keyID); + } publicKey = keystore.get(keyID); } else { + if (LOG.isTraceEnabled()) { + LOG.tracef("Trying to download a public key %s from remote keyservers.", keyID); + } List pgpPublicKeys = null; PGPPublicKey key = null; try { @@ -121,75 +134,122 @@ public SignatureResult validateSignature(ValidationResource artifactSource, Inpu } } catch (PGPException | IOException e) { throw new SignatureException("Unable to parse the certificate downloaded from keyserver", e, - SignatureResult.noSignature(artifactSource)); + SignatureResult.noSignature(artifactId)); } if (key == null) { for (String gpgUrl : gpgUrls) { + if (LOG.isTraceEnabled()) { + LOG.tracef("Trying to download a public key %s from channel defined URL %s.", keyID, gpgUrl); + } try { pgpPublicKeys = downloadPublicKey(gpgUrl); } catch (IOException e) { throw new SignatureException("Unable to parse the certificate downloaded from " + gpgUrl, e, - SignatureResult.noSignature(artifactSource)); + SignatureResult.noSignature(artifactId)); } if (pgpPublicKeys.stream().anyMatch(k -> k.getKeyID() == pgpSignature.getKeyID())) { key = pgpPublicKeys.stream().filter(k -> k.getKeyID() == pgpSignature.getKeyID()).findFirst().get(); break; } } - } - if (key == null) { - return SignatureResult.noMatchingCertificate(artifactSource, keyID); - } else { - if (keystore.add(pgpPublicKeys)) { - publicKey = key; - } else { - return SignatureResult.noMatchingCertificate(artifactSource, keyID); + + if (key == null) { + if (LOG.isTraceEnabled()) { + LOG.tracef("A public key %s not found in the channel defined URLs.", keyID); + } + return SignatureResult.noMatchingCertificate(artifactId, keyID); } } - } - final Iterator subKeys = publicKey.getSignaturesOfType(PGPSignature.SUBKEY_BINDING); - while (subKeys.hasNext()) { - final PGPSignature subKey = subKeys.next(); - final PGPPublicKey masterKey = keystore.get(Long.toHexString(subKey.getKeyID()).toUpperCase(Locale.ROOT)); - if (masterKey.hasRevocation()) { - return SignatureResult.revoked(artifactSource, keyID, getRevocationReason(publicKey)); + + if (keystore.add(pgpPublicKeys)) { + if (LOG.isTraceEnabled()) { + LOG.tracef("Adding a public key %s to the local keystore.", keyID); + } + publicKey = key; + } else { + return SignatureResult.noMatchingCertificate(artifactId, keyID); } } - if (publicKey.hasRevocation()) { - return SignatureResult.revoked(artifactSource, keyID, getRevocationReason(publicKey)); + if (LOG.isTraceEnabled()) { + LOG.tracef("Checking if the public key %s is still valid.", artifactId); + } + SignatureResult res = checkRevoked(artifactId, keyID, publicKey); + if (res.getResult() != SignatureResult.Result.OK) { + return res; } - if (publicKey.getValidSeconds() > 0) { - final Instant expiry = Instant.from(publicKey.getCreationTime().toInstant().plus(publicKey.getValidSeconds(), ChronoUnit.SECONDS)); - if (expiry.isBefore(Instant.now())) { - return SignatureResult.expired(artifactSource, keyID); - } + res = checkExpired(artifactId, publicKey, keyID); + if (res.getResult() != SignatureResult.Result.OK) { + return res; } + if (LOG.isTraceEnabled()) { + LOG.tracef("Verifying that artifact %s has been signed with public key %s.", artifactId, keyID); + } try { pgpSignature.init(new BcPGPContentVerifierBuilderProvider(), publicKey); } catch (PGPException e) { throw new SignatureException("Unable to verify the signature using key " + keyID, e, - SignatureResult.invalid(artifactSource)); + SignatureResult.invalid(artifactId)); } - - final SignatureResult result = verifyFile(artifactSource, artifactStream, pgpSignature); + final SignatureResult result = verifyFile(artifactId, artifactStream, pgpSignature); if (result.getResult() == SignatureResult.Result.OK) { - listener.artifactSignatureCorrect(artifactSource, publicKey); + listener.artifactSignatureCorrect(artifactId, publicKey); } else { - listener.artifactSignatureInvalid(artifactSource, publicKey); + listener.artifactSignatureInvalid(artifactId, publicKey); } return result; } - private static ArtifactCoordinate toArtifactCoordinate(MavenArtifact artifact) { - final ArtifactCoordinate coord = new ArtifactCoordinate(artifact.getGroupId(), artifact.getArtifactId(), artifact.getExtension(), artifact.getClassifier(), artifact.getVersion()); - return coord; + private static SignatureResult checkExpired(ArtifactIdentifier artifactId, PGPPublicKey publicKey, String keyID) { + if (LOG.isTraceEnabled()) { + LOG.tracef("Checking if public key %s is not expired.", keyID); + } + if (publicKey.getValidSeconds() > 0) { + final Instant expiry = Instant.from(publicKey.getCreationTime().toInstant().plus(publicKey.getValidSeconds(), ChronoUnit.SECONDS)); + if (LOG.isTraceEnabled()) { + LOG.tracef("Public key %s expirates on %s.", keyID, expiry); + } + if (expiry.isBefore(Instant.now())) { + return SignatureResult.expired(artifactId, keyID); + } + } else { + if (LOG.isTraceEnabled()) { + LOG.tracef("Public key %s has no expiration.", keyID); + } + } + return SignatureResult.ok(); + } + + private SignatureResult checkRevoked(ArtifactIdentifier artifactId, String keyID, PGPPublicKey publicKey) { + if (LOG.isTraceEnabled()) { + LOG.tracef("Checking if public key %s has been revoked.", keyID); + } + + if (publicKey.hasRevocation()) { + if (LOG.isTraceEnabled()) { + LOG.tracef("Public key %s has been revoked.", keyID); + } + return SignatureResult.revoked(artifactId, keyID, getRevocationReason(publicKey)); + } + + final Iterator subKeys = publicKey.getSignaturesOfType(PGPSignature.SUBKEY_BINDING); + while (subKeys.hasNext()) { + final PGPSignature subKeySignature = subKeys.next(); + final PGPPublicKey subKey = keystore.get(Long.toHexString(subKeySignature.getKeyID()).toUpperCase(Locale.ROOT)); + if (subKey.hasRevocation()) { + if (LOG.isTraceEnabled()) { + LOG.tracef("Sub-key %s has been revoked.", Long.toHexString(subKey.getKeyID()).toUpperCase(Locale.ROOT)); + } + return SignatureResult.revoked(artifactId, keyID, getRevocationReason(publicKey)); + } + } + return SignatureResult.ok(); } private static String getRevocationReason(PGPPublicKey publicKey) { @@ -205,7 +265,7 @@ private static String getRevocationReason(PGPPublicKey publicKey) { return revocationDescription; } - private static SignatureResult verifyFile(ValidationResource artifactSource, InputStream artifactStream, PGPSignature pgpSignature) throws SignatureException { + private static SignatureResult verifyFile(ArtifactIdentifier artifactSource, InputStream artifactStream, PGPSignature pgpSignature) throws SignatureException { // Read file to verify byte[] data = new byte[1024]; InputStream inputStream = null; @@ -257,9 +317,15 @@ private static List downloadPublicKey(String signatureUrl) throws final URI uri = URI.create(signatureUrl); final InputStream inputStream; if (uri.getScheme().equals("classpath")) { + if (LOG.isTraceEnabled()) { + LOG.tracef("Resolving the public key from classpath %s.", uri); + } final String keyPath = uri.getSchemeSpecificPart(); inputStream = GpgSignatureValidator.class.getClassLoader().getResourceAsStream(keyPath); } else { + if (LOG.isTraceEnabled()) { + LOG.tracef("Downloading the public key from %s.", uri); + } final URLConnection urlConnection = uri.toURL().openConnection(); urlConnection.connect(); inputStream = urlConnection.getInputStream(); @@ -275,10 +341,16 @@ private static List downloadPublicKey(String signatureUrl) throws } } - public interface SignatureValidatorListener { + private static class NoopListener implements GpgSignatureValidatorListener { - void artifactSignatureCorrect(ValidationResource artifact, PGPPublicKey publicKey); + @Override + public void artifactSignatureCorrect(ArtifactIdentifier artifact, PGPPublicKey publicKey) { + // noop + } - void artifactSignatureInvalid(ValidationResource artifact, PGPPublicKey publicKey); + @Override + public void artifactSignatureInvalid(ArtifactIdentifier artifact, PGPPublicKey publicKey) { + // noop + } } } diff --git a/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidatorListener.java b/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidatorListener.java new file mode 100644 index 00000000..61167f3c --- /dev/null +++ b/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidatorListener.java @@ -0,0 +1,42 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.wildfly.channel.gpg; + +import org.bouncycastle.openpgp.PGPPublicKey; +import org.wildfly.channel.spi.ArtifactIdentifier; + +/** + * Validation callbacks used for example for additional logging. + */ +public interface GpgSignatureValidatorListener { + + /** + * Called when and artifact signature was successfully verified. + * + * @param artifact - the ID of the artifact being verified + * @param publicKey - the public key used to verify the artifact + */ + void artifactSignatureCorrect(ArtifactIdentifier artifact, PGPPublicKey publicKey); + + /** + * Called when and artifact signature was found to be invalid. + * + * @param artifact - the ID of the artifact being verified + * @param publicKey - the public key used to verify the artifact + */ + void artifactSignatureInvalid(ArtifactIdentifier artifact, PGPPublicKey publicKey); +} diff --git a/gpg-validator/src/main/java/org/wildfly/channel/gpg/Keyserver.java b/gpg-validator/src/main/java/org/wildfly/channel/gpg/Keyserver.java index 16def58c..e2fc4fe7 100644 --- a/gpg-validator/src/main/java/org/wildfly/channel/gpg/Keyserver.java +++ b/gpg-validator/src/main/java/org/wildfly/channel/gpg/Keyserver.java @@ -35,6 +35,9 @@ import java.net.URL; import java.util.List; +/** + * Retrieves a public key from a remote GPG keyserver using a PKS query + */ public class Keyserver { private static final String LOOKUP_PATH = "/pks/lookup"; @@ -45,6 +48,14 @@ public Keyserver(List serverUrls) { this.servers = serverUrls; } + /** + * download a public key matching the {@code keyID} from one of defined GPG keyservers + * + * @param keyID - hex representation of a GPG public key + * @return - the public key associated with the {@code keyID} or null if not found + * @throws PGPException + * @throws IOException + */ public PGPPublicKeyRing downloadKey(String keyID) throws PGPException, IOException { for (URL server : servers) { final PGPPublicKeyRing publicKey = tryDownloadKey(server, keyID); diff --git a/gpg-validator/src/test/java/org/wildfly/channel/gpg/GpgSignatureValidatorTest.java b/gpg-validator/src/test/java/org/wildfly/channel/gpg/GpgSignatureValidatorTest.java index 5a8bac76..8d1dabda 100644 --- a/gpg-validator/src/test/java/org/wildfly/channel/gpg/GpgSignatureValidatorTest.java +++ b/gpg-validator/src/test/java/org/wildfly/channel/gpg/GpgSignatureValidatorTest.java @@ -42,7 +42,7 @@ import org.wildfly.channel.ArtifactCoordinate; import org.wildfly.channel.spi.SignatureResult; import org.wildfly.channel.spi.SignatureValidator; -import org.wildfly.channel.spi.ValidationResource; +import org.wildfly.channel.spi.ArtifactIdentifier; import java.io.File; import java.io.FileInputStream; @@ -70,7 +70,7 @@ public class GpgSignatureValidatorTest { private PGPSecretKeyRing pgpExpiredKeys; private TestKeystore keystore = new TestKeystore(); private GpgSignatureValidator validator; - private ValidationResource.MavenResource anArtifact; + private ArtifactIdentifier.MavenResource anArtifact; private InputStream artifactInputStream; private File artifactFile; private InputStream signatureInputStream; @@ -92,7 +92,7 @@ public void setUp() throws Exception { this.artifactFile = tempDir.resolve("test-one.jar").toFile(); Files.writeString(artifactFile.toPath(), "test"); this.artifactInputStream = new FileInputStream(artifactFile); - anArtifact = new ValidationResource.MavenResource("org.test", "test-one", "jar", null, "1.0.0"); + anArtifact = new ArtifactIdentifier.MavenResource("org.test", "test-one", "jar", null, "1.0.0"); this.signatureFile = signFile(artifactFile, pgpValidKeys); this.signatureInputStream = new FileInputStream(signatureFile); From 76ea7e1d8a8bbb9812c5cb1f8490b4d2cfe8e20c Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Fri, 2 Aug 2024 13:05:28 +0200 Subject: [PATCH 08/10] Add override for manifest signature URL --- .../java/org/wildfly/channel/ChannelImpl.java | 23 +++++++++++++++---- .../channel/ChannelManifestCoordinate.java | 19 ++++++++++++++- .../channel/ChannelMetadataCoordinate.java | 11 +++++++++ .../org/wildfly/channel/v2.1.0/schema.json | 4 ++++ doc/spec.adoc | 3 ++- 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/core/src/main/java/org/wildfly/channel/ChannelImpl.java b/core/src/main/java/org/wildfly/channel/ChannelImpl.java index 0f6fcb24..ff31f863 100644 --- a/core/src/main/java/org/wildfly/channel/ChannelImpl.java +++ b/core/src/main/java/org/wildfly/channel/ChannelImpl.java @@ -162,9 +162,16 @@ private ChannelImpl createNewChannelFromMaven(MavenVersionsResolver.Factory fact version = latest.orElseThrow(() -> new RuntimeException(String.format("Can not determine the latest version for Maven artifact %s:%s:%s:%s", groupId, artifactId, ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER))); } - final ChannelImpl requiredChannel = new ChannelImpl(new Channel(ChannelMapper.CURRENT_SCHEMA_VERSION, null, null, null, channelDefinition.getRepositories(), - new ChannelManifestCoordinate(groupId, artifactId, version), null, - Channel.NoStreamStrategy.NONE, channelDefinition.isGpgCheck(), channelDefinition.getGpgUrls())); + final Channel requiredChannelDefinition = new Channel.Builder(channelDefinition) + .setName(null) + .setDescription(null) + .setVendor(null) + .setManifestCoordinate(groupId, artifactId, version) + .setResolveStrategy(Channel.NoStreamStrategy.NONE) + .build(); + + final ChannelImpl requiredChannel = new ChannelImpl(requiredChannelDefinition); + try { requiredChannel.init(factory, channels, signatureValidator); } catch (UnresolvedMavenArtifactException e) { @@ -313,7 +320,7 @@ private ChannelManifest resolveManifest(ChannelManifestCoordinate manifestCoordi * * @throws ArtifactTransferException if any artifacts can not be resolved. */ - public List resolveChannelMetadata(List coords, boolean optional) throws ArtifactTransferException { + private List resolveChannelMetadata(List coords, boolean optional) throws ArtifactTransferException { requireNonNull(coords); List channels = new ArrayList<>(); @@ -324,7 +331,13 @@ public List resolveChannelMetadata(List>). *** `url` corresponding to a URL where the manifest file can be found. +*** `signature-url` corresponding to a URL where the signature of the manifest file can be found. * Optional `blocklist` corresponding to the Blocklist artifact. Blocklist is used to define versions of artifacts excluded from a channel. ** One of the following, mutually exclusive fields, used to resolve the Blocklist: *** `maven` corresponds to Maven coordinates of the Blocklist. It's composed of: @@ -248,7 +249,7 @@ The signature file is resolved at the same time as the artifact. If the signatur #### Verifing manifest signatures -If the manifest of a channel is defined as a Maven GA(V), it is treated as any other maven artifact. If it is defined as an URL, the signature file must be available at the same URL with ".asc" suffix. If the signature cannot be resolved, the channel creation must fail. +If the manifest of a channel is defined as a Maven GA(V), it is treated as any other maven artifact. If it is defined as an URL, the signature file must be available at the same URL with ".asc" suffix. Alternatively, a `signature-url` element can be used to provide a location of the signature. If the signature cannot be resolved, the channel creation must fail. #### Public keys From 091934d38eae2beb4129c76916e69f07f01afdf4 Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Thu, 5 Sep 2024 16:08:04 +0100 Subject: [PATCH 09/10] Move signature validation from ChannelSession to the VersionResolver --- .../java/org/wildfly/channel/ChannelImpl.java | 179 +------------- .../channel/ChannelManifestCoordinate.java | 2 +- .../org/wildfly/channel/ChannelSession.java | 127 +++++++--- .../channel/SignedVersionResolverWrapper.java | 222 ++++++++++++++++++ .../channel/spi/MavenVersionsResolver.java | 11 +- .../channel/ChannelSessionInitTestCase.java | 107 +-------- .../channel/ChannelSessionTestCase.java | 119 +++++++++- ...essionWithSignatureValidationTestCase.java | 175 -------------- .../channel/ChannelWithBlocklistTestCase.java | 96 ++++---- .../ChannelWithRequirementsTestCase.java | 38 +-- .../SignedVersionResolverWrapperTest.java | 186 +++++++++++++++ .../channel/mapping/ChannelTestCase.java | 2 +- doc/spec.adoc | 13 +- .../channel/maven/VersionResolverFactory.java | 30 ++- .../maven/VersionResolverFactoryTest.java | 26 +- 15 files changed, 735 insertions(+), 598 deletions(-) create mode 100644 core/src/main/java/org/wildfly/channel/SignedVersionResolverWrapper.java delete mode 100644 core/src/test/java/org/wildfly/channel/ChannelSessionWithSignatureValidationTestCase.java create mode 100644 core/src/test/java/org/wildfly/channel/SignedVersionResolverWrapperTest.java diff --git a/core/src/main/java/org/wildfly/channel/ChannelImpl.java b/core/src/main/java/org/wildfly/channel/ChannelImpl.java index ff31f863..51f13630 100644 --- a/core/src/main/java/org/wildfly/channel/ChannelImpl.java +++ b/core/src/main/java/org/wildfly/channel/ChannelImpl.java @@ -21,10 +21,6 @@ import static org.wildfly.channel.version.VersionMatcher.COMPARATOR; import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; import java.util.Collections; @@ -38,9 +34,6 @@ import org.jboss.logging.Logger; import org.wildfly.channel.spi.MavenVersionsResolver; -import org.wildfly.channel.spi.SignatureResult; -import org.wildfly.channel.spi.SignatureValidator; -import org.wildfly.channel.spi.ArtifactIdentifier; import org.wildfly.channel.version.VersionMatcher; /** @@ -49,7 +42,6 @@ class ChannelImpl implements AutoCloseable { private static final Logger LOG = Logger.getLogger(ChannelImpl.class); - protected static final String SIGNATURE_FILE_SUFFIX = ".asc"; private final Channel channelDefinition; private Channel resolvedChannel; @@ -64,11 +56,8 @@ class ChannelImpl implements AutoCloseable { private boolean dependency = false; public Optional blocklist = Optional.empty(); - private ChannelManifestCoordinate resolvedCoordinate; - private SignatureValidator signatureValidator; - public ChannelManifest getManifest() { return channelManifest; } @@ -82,18 +71,16 @@ public ChannelImpl(Channel channelDefinition) { * * @param factory * @param channels - * @param signatureValidator - the validator used to check the signatures of resolved artifacts * @throws UnresolvedRequiredManifestException - if a required manifest cannot be resolved either via maven coordinates or in the list of channels * @throws CyclicDependencyException - if the required manifests form a cyclic dependency */ - void init(MavenVersionsResolver.Factory factory, List channels, SignatureValidator signatureValidator) { + void init(MavenVersionsResolver.Factory factory, List channels) { if (resolver != null) { //already initialized return; } - this.signatureValidator = signatureValidator; - resolver = factory.create(channelDefinition.getRepositories()); + resolver = factory.create(channelDefinition); final Channel.Builder resolvedChannelBuilder = new Channel.Builder(channelDefinition); if (channelDefinition.getManifestCoordinate() != null) { @@ -117,7 +104,7 @@ void init(MavenVersionsResolver.Factory factory, List channels, Sig BlocklistCoordinate blocklistCoordinate = resolveBlocklistVersion(channelDefinition); if (blocklistCoordinate != null) { resolvedChannelBuilder.setBlocklistCoordinate(blocklistCoordinate); - final List urls = resolveChannelMetadata(List.of(blocklistCoordinate), true); + final List urls = resolver.resolveChannelMetadata(List.of(blocklistCoordinate)); this.blocklist = urls.stream() .map(Blocklist::from) .findFirst(); @@ -131,7 +118,7 @@ private ChannelImpl findRequiredChannel(MavenVersionsResolver.Factory factory, L ChannelImpl foundChannel = null; for (ChannelImpl c: channels) { if (c.getManifest() == null) { - c.init(factory, channels, signatureValidator); + c.init(factory, channels); } if (manifestRequirement.getId().equals(c.getManifest().getId())) { foundChannel = c; @@ -173,7 +160,7 @@ private ChannelImpl createNewChannelFromMaven(MavenVersionsResolver.Factory fact final ChannelImpl requiredChannel = new ChannelImpl(requiredChannelDefinition); try { - requiredChannel.init(factory, channels, signatureValidator); + requiredChannel.init(factory, channels); } catch (UnresolvedMavenArtifactException e) { throw new UnresolvedRequiredManifestException("Manifest with ID " + manifestRequirement.getId() + " is not available", manifestRequirement.getId(), e); } @@ -229,6 +216,10 @@ static class ResolveLatestVersionResult { } } + private Set attemptedRepositories() { + return new HashSet<>(channelDefinition.getRepositories()); + } + private ChannelManifestCoordinate resolveManifestVersion(Channel baseDefinition) { final ChannelManifestCoordinate manifestCoordinate = baseDefinition.getManifestCoordinate(); @@ -297,92 +288,12 @@ private ChannelManifest resolveManifest(ChannelManifestCoordinate manifestCoordi } else { resolvedCoordinate = manifestCoordinate; } - return resolveChannelMetadata(List.of(resolvedCoordinate), false) + return resolver.resolveChannelMetadata(List.of(resolvedCoordinate)) .stream() .map(ChannelManifestMapper::from) .findFirst().orElseThrow(); } - /** - * Resolve a list of channel metadata artifacts based on the coordinates. - * If the {@code ChannelMetadataCoordinate} contains non-null URL, that URL is returned. - * If the {@code ChannelMetadataCoordinate} contains non-null Maven coordinates, the Maven artifact will be resolved - * and a URL to it will be returned. - * If the Maven coordinates specify only groupId and artifactId, latest available version of matching Maven artifact - * will be resolved. - * - * The order of returned URLs is the same as order of coordinates. - * - * @param coords - list of ChannelMetadataCoordinate. - * @param optional - if artifact is optional, the method will return an empty collection if no versions are found - * - * @return a list of URLs to the metadata files - * - * @throws ArtifactTransferException if any artifacts can not be resolved. - */ - private List resolveChannelMetadata(List coords, boolean optional) throws ArtifactTransferException { - requireNonNull(coords); - - List channels = new ArrayList<>(); - - for (ChannelMetadataCoordinate coord : coords) { - if (coord.getUrl() != null) { - LOG.infof("Resolving channel metadata at %s", coord.getUrl()); - channels.add(coord.getUrl()); - if (channelDefinition.isGpgCheck()) { - try { - final URL signatureUrl; - if (coord.getSignatureUrl() == null) { - signatureUrl = new URL(coord.getUrl().toExternalForm() + SIGNATURE_FILE_SUFFIX); - } else { - signatureUrl = coord.getSignatureUrl(); - } - validateGpgSignature(coord.getUrl(), signatureUrl); - } catch (IOException e) { - throw new InvalidChannelMetadataException("Unable to download a detached signature file from: " + coord.getUrl().toExternalForm()+ SIGNATURE_FILE_SUFFIX, - List.of(e.getMessage()), e); - } - } - continue; - } - - String version = coord.getVersion(); - if (version == null) { - Set versions = resolver.getAllVersions(coord.getGroupId(), coord.getArtifactId(), coord.getExtension(), coord.getClassifier()); - Optional latestVersion = VersionMatcher.getLatestVersion(versions); - if (latestVersion.isPresent()){ - version = latestVersion.get(); - } else if (optional) { - return Collections.emptyList(); - } else { - throw new ArtifactTransferException(String.format("Unable to resolve the latest version of channel metadata %s:%s", coord.getGroupId(), coord.getArtifactId()), - singleton(new ArtifactCoordinate(coord.getGroupId(), coord.getArtifactId(), coord.getExtension(), coord.getClassifier(), "")), - attemptedRepositories()); - } - } - LOG.infof("Resolving channel metadata from Maven artifact %s:%s:%s", coord.getGroupId(), coord.getArtifactId(), version); - File channelArtifact = resolver.resolveArtifact(coord.getGroupId(), coord.getArtifactId(), coord.getExtension(), coord.getClassifier(), version); - try { - channels.add(channelArtifact.toURI().toURL()); - if (channelDefinition.isGpgCheck()) { - validateGpgSignature(coord.getGroupId(), coord.getArtifactId(), coord.getExtension(), coord.getClassifier(), version, channelArtifact); - } - } catch (MalformedURLException e) { - throw new ArtifactTransferException(String.format("Unable to resolve the latest version of channel metadata %s:%s", coord.getGroupId(), coord.getArtifactId()), e, - singleton(new ArtifactCoordinate(coord.getGroupId(), coord.getArtifactId(), - coord.getExtension(), coord.getClassifier(), coord.getVersion())), - attemptedRepositories()); - } - } - return channels; - } - - private Set attemptedRepositories() { - return channelDefinition.getRepositories().stream() - .map(r -> new Repository(r.getId(), r.getUrl())) - .collect(Collectors.toSet()); - } - Optional resolveLatestVersion(String groupId, String artifactId, String extension, String classifier, String baseVersion) { requireNonNull(groupId); requireNonNull(artifactId); @@ -497,79 +408,11 @@ ResolveArtifactResult resolveArtifact(String groupId, String artifactId, String } } - final File artifact = resolver.resolveArtifact(groupId, artifactId, extension, classifier, version); - if (channelDefinition.isGpgCheck()) { - validateGpgSignature(groupId, artifactId, extension, classifier, version, artifact); - } - return new ResolveArtifactResult(artifact, this); - } - - private void validateGpgSignature(String groupId, String artifactId, String extension, String classifier, - String version, File artifact) { - final ArtifactIdentifier mavenArtifact = new ArtifactIdentifier.MavenResource(groupId, artifactId, extension, - classifier, version); - try { - final File signature = resolver.resolveArtifact(groupId, artifactId, extension + SIGNATURE_FILE_SUFFIX, - classifier, version); - final SignatureResult signatureResult = signatureValidator.validateSignature( - mavenArtifact, new FileInputStream(artifact), new FileInputStream(signature), - channelDefinition.getGpgUrls()); - if (signatureResult.getResult() != SignatureResult.Result.OK) { - throw new SignatureValidator.SignatureException("Failed to verify an artifact signature", signatureResult); - } - } catch (ArtifactTransferException | FileNotFoundException e) { - throw new SignatureValidator.SignatureException("Unable to find required signature for " + mavenArtifact, - e, SignatureResult.noSignature(mavenArtifact)); - } - } - - private void validateGpgSignature(URL artifactFile, URL signature) throws IOException { - final SignatureResult signatureResult = signatureValidator.validateSignature( - new ArtifactIdentifier.UrlResource(artifactFile), - artifactFile.openStream(), signature.openStream(), - channelDefinition.getGpgUrls() - ); - - if (signatureResult.getResult() != SignatureResult.Result.OK) { - throw new SignatureValidator.SignatureException("Failed to verify an artifact signature", signatureResult); - } + return new ResolveArtifactResult(resolver.resolveArtifact(groupId, artifactId, extension, classifier, version), this); } List resolveArtifacts(List coordinates) throws UnresolvedMavenArtifactException { final List resolvedArtifacts = resolver.resolveArtifacts(coordinates); - - if (channelDefinition.isGpgCheck()) { - try { - final List signatures = resolver.resolveArtifacts(coordinates.stream() - .map(c->new ArtifactCoordinate(c.getGroupId(), c.getArtifactId(), c.getExtension() + SIGNATURE_FILE_SUFFIX, - c.getClassifier(), c.getVersion())) - .collect(Collectors.toList())); - for (int i = 0; i < resolvedArtifacts.size(); i++) { - final File artifact = resolvedArtifacts.get(i); - final ArtifactCoordinate c = coordinates.get(i); - final ArtifactIdentifier.MavenResource mavenArtifact = new ArtifactIdentifier.MavenResource(c.getGroupId(), c.getArtifactId(), - c.getExtension(), c.getClassifier(), c.getVersion()); - final File signature = signatures.get(i); - try { - final SignatureResult signatureResult = signatureValidator.validateSignature(mavenArtifact, - new FileInputStream(artifact), new FileInputStream(signature), channelDefinition.getGpgUrls()); - if (signatureResult.getResult() != SignatureResult.Result.OK) { - throw new SignatureValidator.SignatureException("Failed to verify an artifact signature", signatureResult); - } - } catch (FileNotFoundException e) { - throw new SignatureValidator.SignatureException(String.format("Unable to find required signature for %s:%s:%s", - mavenArtifact.getGroupId(), mavenArtifact.getArtifactId(), mavenArtifact.getVersion()), - SignatureResult.noSignature(mavenArtifact)); - } - } - } catch (ArtifactTransferException e) { - final ArtifactIdentifier.MavenResource artifact = new ArtifactIdentifier.MavenResource(e.getUnresolvedArtifacts().stream().findFirst().get()); - throw new SignatureValidator.SignatureException(String.format("Unable to find required signature for %s:%s:%s", - artifact.getGroupId(), artifact.getArtifactId(), artifact.getVersion()), - SignatureResult.noSignature(artifact)); - } - } - return resolvedArtifacts.stream().map(f->new ResolveArtifactResult(f, this)).collect(Collectors.toList()); } diff --git a/core/src/main/java/org/wildfly/channel/ChannelManifestCoordinate.java b/core/src/main/java/org/wildfly/channel/ChannelManifestCoordinate.java index 3522cb2a..1f7aa143 100644 --- a/core/src/main/java/org/wildfly/channel/ChannelManifestCoordinate.java +++ b/core/src/main/java/org/wildfly/channel/ChannelManifestCoordinate.java @@ -69,7 +69,7 @@ public static ChannelManifestCoordinate create(@JsonProperty(value = "url") Stri return new ChannelManifestCoordinate(gav.getGroupId(), gav.getArtifactId(), gav.getVersion()); } } else { - return new ChannelManifestCoordinate(new URL(url)); + return new ChannelManifestCoordinate(new URL(url), signatureUrl == null ? null : new URL(signatureUrl)); } } diff --git a/core/src/main/java/org/wildfly/channel/ChannelSession.java b/core/src/main/java/org/wildfly/channel/ChannelSession.java index 5e37c659..d1507b73 100644 --- a/core/src/main/java/org/wildfly/channel/ChannelSession.java +++ b/core/src/main/java/org/wildfly/channel/ChannelSession.java @@ -33,9 +33,9 @@ import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ForkJoinTask; +import org.apache.commons.lang3.tuple.Pair; import org.jboss.logging.Logger; import org.wildfly.channel.spi.MavenVersionsResolver; -import org.wildfly.channel.spi.SignatureValidator; import org.wildfly.channel.version.VersionMatcher; /** @@ -48,12 +48,10 @@ public class ChannelSession implements AutoCloseable { private final List channels; private final ChannelRecorder recorder = new ChannelRecorder(); - // resolver used for direct dependencies only. Uses combination of all repositories in the channels. - private final MavenVersionsResolver combinedResolver; private final int versionResolutionParallelism; /** - * Create a ChannelSession with a default rejecting signature validator. + * Create a ChannelSession. * * @param channelDefinitions the list of channels to resolve Maven artifact * @param factory Factory to create {@code MavenVersionsResolver} that are performing the actual Maven resolution. @@ -61,20 +59,7 @@ public class ChannelSession implements AutoCloseable { * @throws CyclicDependencyException - if the required manifests form a cyclic dependency */ public ChannelSession(List channelDefinitions, MavenVersionsResolver.Factory factory) { - this(channelDefinitions, factory, DEFAULT_SPLIT_ARTIFACT_PARALLELISM, SignatureValidator.REJECTING_VALIDATOR); - } - - /** - * Create a ChannelSession with a default rejecting signature validator. - * - * @param channelDefinitions the list of channels to resolve Maven artifact - * @param factory Factory to create {@code MavenVersionsResolver} that are performing the actual Maven resolution. - * @param signatureValidator Validator to verify signatures of downloaded artifacts - * @throws UnresolvedRequiredManifestException - if a required manifest cannot be resolved either via maven coordinates or in the list of channels - * @throws CyclicDependencyException - if the required manifests form a cyclic dependency - */ - public ChannelSession(List channelDefinitions, MavenVersionsResolver.Factory factory, SignatureValidator signatureValidator) { - this(channelDefinitions, factory, DEFAULT_SPLIT_ARTIFACT_PARALLELISM, signatureValidator); + this(channelDefinitions, factory, DEFAULT_SPLIT_ARTIFACT_PARALLELISM); } /** @@ -86,18 +71,13 @@ public ChannelSession(List channelDefinitions, MavenVersionsResolver.Fa * @throws UnresolvedRequiredManifestException - if a required manifest cannot be resolved either via maven coordinates or in the list of channels * @throws CyclicDependencyException - if the required manifests form a cyclic dependency */ - public ChannelSession(List channelDefinitions, MavenVersionsResolver.Factory factory, - int versionResolutionParallelism, SignatureValidator signatureValidator) { + public ChannelSession(List channelDefinitions, MavenVersionsResolver.Factory factory, int versionResolutionParallelism) { requireNonNull(channelDefinitions); requireNonNull(factory); - requireNonNull(signatureValidator); - - final Set repositories = channelDefinitions.stream().flatMap(c -> c.getRepositories().stream()).collect(Collectors.toSet()); - this.combinedResolver = factory.create(repositories); List channelList = channelDefinitions.stream().map(ChannelImpl::new).collect(Collectors.toList()); for (ChannelImpl channel : channelList) { - channel.init(factory, channelList, signatureValidator); + channel.init(factory, channelList); } // filter out channels marked as dependency, so that resolution starts only at top level channels this.channels = channelList.stream().filter(c->!c.isDependency()).collect(Collectors.toList()); @@ -218,9 +198,37 @@ public MavenArtifact resolveDirectMavenArtifact(String groupId, String artifactI requireNonNull(artifactId); requireNonNull(version); - File file = combinedResolver.resolveArtifact(groupId, artifactId, extension, classifier, version); - recorder.recordStream(groupId, artifactId, version); - return new MavenArtifact(groupId, artifactId, extension, classifier, version, file); + /* + * when resolving a direct artifact we don't care if a channel manifest lists that artifact, we resolve it + * if it is present in underlying repositories. + * BUT if the channel requires a GPG check, the artifact has to still be verified. + * Therefore, we're trying to resolve artifact from each available channel and returning the first match. + */ + + File file = null; + UnresolvedMavenArtifactException ex = null; + for (ChannelImpl channel : channels) { + try { + file = channel.resolveArtifact(groupId, artifactId, extension, classifier, version).file; + break; + } catch (UnresolvedMavenArtifactException e) { + ex = e; + } + } + + if (file != null) { + recorder.recordStream(groupId, artifactId, version); + return new MavenArtifact(groupId, artifactId, extension, classifier, version, file); + } else if (ex != null) { + throw ex; + } else { + throw new ArtifactTransferException("Unable to resolve direct artifact.", + Set.of(new ArtifactCoordinate(groupId, artifactId, extension, classifier, version)), + channels.stream() + .map(ChannelImpl::getResolvedChannelDefinition) + .flatMap(cd->cd.getRepositories().stream()) + .collect(Collectors.toSet())); + } } /** @@ -238,16 +246,64 @@ public List resolveDirectMavenArtifacts(List requireNonNull(c.getArtifactId()); requireNonNull(c.getVersion()); }); - final List files = combinedResolver.resolveArtifacts(coordinates); - final ArrayList res = new ArrayList<>(); - for (int i = 0; i < coordinates.size(); i++) { - final ArtifactCoordinate request = coordinates.get(i); - final MavenArtifact resolvedArtifact = new MavenArtifact(request.getGroupId(), request.getArtifactId(), request.getExtension(), request.getClassifier(), request.getVersion(), files.get(i)); + /* + * When resolving a "direct" artifact, we don't care if the artifact is listed in the channel's manifest, + * only if the underlying repositories contain that artifact. + * BUT, we still need to verify the artifact signature if the channel requires it. To achieve that, we're + * going to query each channel in turn, taking the artifacts it was able to resolve and keeping the rest + * to be resolved by remaining channels. At the end we should be left with no un-resolved artifacts, or have + * a list of artifacts not available in any channels. + * NOTE: if the same artifact is available in both GPG-enabled and GPG-disabled channel there is no guarantee + * which channel will be queried first. + */ + + // list of artifacts that are being resolved in this step + List currentQuery = new ArrayList<>(coordinates); + // we need to preserve the ordering of artifacts, but that can be affected by the order of query/resolution between channels + final HashMap> resolvedArtifacts = new HashMap<>(); + for (ChannelImpl channel : channels) { + if (currentQuery.isEmpty()) { + break; + } - recorder.recordStream(resolvedArtifact.getGroupId(), resolvedArtifact.getArtifactId(), resolvedArtifact.getVersion()); - res.add(resolvedArtifact); + try { + final List resolved = channel.resolveArtifacts(currentQuery); + // keep a map of AC -> File + for (int i = 0; i < currentQuery.size(); i++) { + resolvedArtifacts.put(currentQuery.get(i), Pair.of(resolved.get(i).file, channel.getResolvedChannelDefinition().getName())); + } + // all the artifacts were resolved by this point, lets remove all artifacts from the current query + currentQuery = Collections.emptyList(); + } catch (UnresolvedMavenArtifactException e) { + // at the end need to map them into a correct order + final Set unresolvedArtifacts = e.getUnresolvedArtifacts(); + // coordinates - unresolved = it should be possible to resolve those artifacts from this channel + // we need to call resolve again, because the first call threw an exception + currentQuery.removeAll(unresolvedArtifacts); + final List resolved = channel.resolveArtifacts(currentQuery); + for (int i = 0; i < currentQuery.size(); i++) { + resolvedArtifacts.put(currentQuery.get(i), Pair.of(resolved.get(i).file, channel.getResolvedChannelDefinition().getName())); + } + // unresolved - try with another channel, rinse and repeat until run out of channels or resolve all artifacts + currentQuery = new ArrayList<>(unresolvedArtifacts); + } } + if (!currentQuery.isEmpty()) { + throw new ArtifactTransferException("Unable to resolve some direct artifacts", new HashSet<>(currentQuery), + channels.stream() + .map(ChannelImpl::getResolvedChannelDefinition) + .flatMap(cd->cd.getRepositories().stream()) + .collect(Collectors.toSet())); + } + + // finally, build a list of resolved files in a correct order to return and record the streams + final List res = coordinates.stream().map(ac -> new MavenArtifact(ac.getGroupId(), ac.getArtifactId(), ac.getExtension(), + ac.getClassifier(), ac.getVersion(), resolvedArtifacts.get(ac).getLeft(), resolvedArtifacts.get(ac).getRight())).collect(Collectors.toList()); + + res.forEach(resolvedArtifact-> + recorder.recordStream(resolvedArtifact.getGroupId(), resolvedArtifact.getArtifactId(), resolvedArtifact.getVersion()) + ); return res; } @@ -273,7 +329,6 @@ public void close() { for (ChannelImpl channel : channels) { channel.close(); } - combinedResolver.close(); } /** diff --git a/core/src/main/java/org/wildfly/channel/SignedVersionResolverWrapper.java b/core/src/main/java/org/wildfly/channel/SignedVersionResolverWrapper.java new file mode 100644 index 00000000..aa5d060c --- /dev/null +++ b/core/src/main/java/org/wildfly/channel/SignedVersionResolverWrapper.java @@ -0,0 +1,222 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.wildfly.channel; + +import static java.util.Collections.singleton; +import static java.util.Objects.requireNonNull; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.wildfly.channel.spi.ArtifactIdentifier; +import org.wildfly.channel.spi.MavenVersionsResolver; +import org.wildfly.channel.spi.SignatureResult; +import org.wildfly.channel.spi.SignatureValidator; +import org.wildfly.channel.version.VersionMatcher; + +/** + * Resolve and validate a signature using the wrapped {@code MavenVersionsResolver}. + */ +public class SignedVersionResolverWrapper implements MavenVersionsResolver { + + protected static final String SIGNATURE_FILE_SUFFIX = ".asc"; + private final MavenVersionsResolver wrapped; + private final SignatureValidator signatureValidator; + private final List gpgUrls; + private final Collection repositories; + + public SignedVersionResolverWrapper(MavenVersionsResolver wrapped, Collection repositories, + SignatureValidator signatureValidator, List gpgUrls) { + this.wrapped = wrapped; + this.repositories = repositories; + this.signatureValidator = signatureValidator; + this.gpgUrls = gpgUrls; + } + + private void validateGpgSignature(String groupId, String artifactId, String extension, String classifier, + String version, File artifact) { + final ArtifactIdentifier mavenArtifact = new ArtifactIdentifier.MavenResource(groupId, artifactId, extension, + classifier, version); + try { + final File signature = wrapped.resolveArtifact(groupId, artifactId, extension + SIGNATURE_FILE_SUFFIX, + classifier, version); + final SignatureResult signatureResult = signatureValidator.validateSignature( + mavenArtifact, new FileInputStream(artifact), new FileInputStream(signature), + gpgUrls); + if (signatureResult.getResult() != SignatureResult.Result.OK) { + throw new SignatureValidator.SignatureException("Failed to verify an artifact signature", signatureResult); + } + } catch (ArtifactTransferException | FileNotFoundException e) { + throw new SignatureValidator.SignatureException("Unable to find required signature for " + mavenArtifact, + e, SignatureResult.noSignature(mavenArtifact)); + } + } + + private void validateGpgSignature(URL artifactFile, URL signature) throws IOException { + final SignatureResult signatureResult = signatureValidator.validateSignature( + new ArtifactIdentifier.UrlResource(artifactFile), + artifactFile.openStream(), signature.openStream(), + gpgUrls + ); + + if (signatureResult.getResult() != SignatureResult.Result.OK) { + throw new SignatureValidator.SignatureException("Failed to verify an artifact signature", signatureResult); + } + } + + @Override + public Set getAllVersions(String groupId, String artifactId, String extension, String classifier) { + return wrapped.getAllVersions(groupId, artifactId, extension, classifier); + } + + @Override + public File resolveArtifact(String groupId, String artifactId, String extension, String classifier, String version) throws ArtifactTransferException { + final File artifact = wrapped.resolveArtifact(groupId, artifactId, extension, classifier, version); + + validateGpgSignature(groupId, artifactId, extension, classifier, version, artifact); + + return artifact; + } + + @Override + public List resolveArtifacts(List coordinates) throws ArtifactTransferException { + final List resolvedArtifacts = wrapped.resolveArtifacts(coordinates); + + try { + final List signatures = wrapped.resolveArtifacts(coordinates.stream() + .map(c->new ArtifactCoordinate(c.getGroupId(), c.getArtifactId(), c.getExtension() + SIGNATURE_FILE_SUFFIX, + c.getClassifier(), c.getVersion())) + .collect(Collectors.toList())); + for (int i = 0; i < resolvedArtifacts.size(); i++) { + final File artifact = resolvedArtifacts.get(i); + final ArtifactCoordinate c = coordinates.get(i); + final ArtifactIdentifier.MavenResource mavenArtifact = new ArtifactIdentifier.MavenResource(c.getGroupId(), c.getArtifactId(), + c.getExtension(), c.getClassifier(), c.getVersion()); + final File signature = signatures.get(i); + try { + final SignatureResult signatureResult = signatureValidator.validateSignature(mavenArtifact, + new FileInputStream(artifact), new FileInputStream(signature), gpgUrls); + if (signatureResult.getResult() != SignatureResult.Result.OK) { + throw new SignatureValidator.SignatureException("Failed to verify an artifact signature", signatureResult); + } + } catch (FileNotFoundException e) { + throw new SignatureValidator.SignatureException(String.format("Unable to find required signature for %s:%s:%s", + mavenArtifact.getGroupId(), mavenArtifact.getArtifactId(), mavenArtifact.getVersion()), + SignatureResult.noSignature(mavenArtifact)); + } + } + } catch (ArtifactTransferException e) { + final ArtifactIdentifier.MavenResource artifact = new ArtifactIdentifier.MavenResource(e.getUnresolvedArtifacts().stream().findFirst().get()); + throw new SignatureValidator.SignatureException(String.format("Unable to find required signature for %s:%s:%s", + artifact.getGroupId(), artifact.getArtifactId(), artifact.getVersion()), + SignatureResult.noSignature(artifact)); + } + + + return resolvedArtifacts; + } + + @Override + public List resolveChannelMetadata(List coords) throws ArtifactTransferException { + requireNonNull(coords); + + final List resolvedMetadata = wrapped.resolveChannelMetadata(coords); + + List signatures = new ArrayList<>(); + + for (ChannelMetadataCoordinate coord : coords) { + if (coord.getUrl() != null) { + try { + final URL signatureUrl; + if (coord.getSignatureUrl() == null) { + signatureUrl = new URL(coord.getUrl().toExternalForm() + SIGNATURE_FILE_SUFFIX); + } else { + signatureUrl = coord.getSignatureUrl(); + } + signatures.add(signatureUrl); + } catch (IOException e) { + throw new InvalidChannelMetadataException("Unable to download a detached signature file from: " + coord.getUrl().toExternalForm()+ SIGNATURE_FILE_SUFFIX, + List.of(e.getMessage()), e); + } + continue; + } + + String version = coord.getVersion(); + if (version == null) { + Set versions = wrapped.getAllVersions(coord.getGroupId(), coord.getArtifactId(), coord.getExtension(), coord.getClassifier()); + Optional latestVersion = VersionMatcher.getLatestVersion(versions); + if (latestVersion.isPresent()){ + version = latestVersion.get(); + } else { + throw new ArtifactTransferException(String.format("Unable to resolve the latest version of channel metadata signature %s:%s", coord.getGroupId(), coord.getArtifactId()), + singleton(new ArtifactCoordinate(coord.getGroupId(), coord.getArtifactId(), coord.getExtension(), coord.getClassifier(), "")), + attemptedRepositories()); + } + } + + try { + File channelArtifact = wrapped.resolveArtifact(coord.getGroupId(), coord.getArtifactId(), + coord.getExtension() + SIGNATURE_FILE_SUFFIX, coord.getClassifier(), version); + signatures.add(channelArtifact.toURI().toURL()); + } catch (ArtifactTransferException e) { + throw new SignatureValidator.SignatureException("Unable to find required signature for " + coord, + e, SignatureResult.noSignature(new ArtifactIdentifier.MavenResource(coord.getGroupId(), coord.getArtifactId(), coord.getExtension(), coord.getClassifier(), version))); + } catch (MalformedURLException e) { + throw new ArtifactTransferException(String.format("Unable to resolve the latest version of channel metadata signature %s:%s", coord.getGroupId(), coord.getArtifactId()), e, + singleton(new ArtifactCoordinate(coord.getGroupId(), coord.getArtifactId(), + coord.getExtension(), coord.getClassifier(), coord.getVersion())), + attemptedRepositories()); + } + } + + try { + for (int i = 0; i < resolvedMetadata.size(); i++) { + validateGpgSignature(resolvedMetadata.get(i), signatures.get(i)); + } + } catch (IOException e) { + throw new InvalidChannelMetadataException("Unable to read a detached signature file from: " + signatures, + List.of(e.getMessage()), e); + } + return resolvedMetadata; + } + + private Set attemptedRepositories() { + return repositories.stream() + .map(r -> new Repository(r.getId(), r.getUrl())) + .collect(Collectors.toSet()); + } + + @Override + public String getMetadataReleaseVersion(String groupId, String artifactId) { + return wrapped.getMetadataReleaseVersion(groupId, artifactId); + } + + @Override + public String getMetadataLatestVersion(String groupId, String artifactId) { + return wrapped.getMetadataLatestVersion(groupId, artifactId); + } +} diff --git a/core/src/main/java/org/wildfly/channel/spi/MavenVersionsResolver.java b/core/src/main/java/org/wildfly/channel/spi/MavenVersionsResolver.java index 59c7685f..ecc04c85 100644 --- a/core/src/main/java/org/wildfly/channel/spi/MavenVersionsResolver.java +++ b/core/src/main/java/org/wildfly/channel/spi/MavenVersionsResolver.java @@ -19,14 +19,13 @@ import java.io.Closeable; import java.io.File; import java.net.URL; -import java.util.Collection; import java.util.List; import java.util.Set; import org.wildfly.channel.ArtifactCoordinate; import org.wildfly.channel.ArtifactTransferException; +import org.wildfly.channel.Channel; import org.wildfly.channel.ChannelMetadataCoordinate; -import org.wildfly.channel.Repository; import org.wildfly.channel.UnresolvedMavenArtifactException; /** @@ -60,8 +59,7 @@ public interface MavenVersionsResolver extends Closeable { * * @throws ArtifactTransferException if the artifact can not be resolved. */ - File resolveArtifact(String groupId, String artifactId, String extension, String classifier, String version) - throws ArtifactTransferException; + File resolveArtifact(String groupId, String artifactId, String extension, String classifier, String version) throws ArtifactTransferException; /** * Resolve a list of maven artifacts based on the full coordinates. @@ -92,7 +90,6 @@ File resolveArtifact(String groupId, String artifactId, String extension, String * * @throws ArtifactTransferException if any artifacts can not be resolved. */ - @Deprecated List resolveChannelMetadata(List manifestCoords) throws ArtifactTransferException; /** @@ -128,11 +125,11 @@ default void close() { * * A client of this library is responsible to provide an implementation of the {@link Factory} interface. * - * The {@link #create(Collection)}} method will be called once for every channel. + * The {@link #create(Channel)}} method will be called once for every channel. */ interface Factory extends Closeable { - MavenVersionsResolver create(Collection repositories); + MavenVersionsResolver create(Channel channel); default void close() { } diff --git a/core/src/test/java/org/wildfly/channel/ChannelSessionInitTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelSessionInitTestCase.java index 8737c1e0..3f1a8d9a 100644 --- a/core/src/test/java/org/wildfly/channel/ChannelSessionInitTestCase.java +++ b/core/src/test/java/org/wildfly/channel/ChannelSessionInitTestCase.java @@ -21,12 +21,10 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.wildfly.channel.spi.MavenVersionsResolver; -import org.wildfly.channel.spi.SignatureResult; -import org.wildfly.channel.spi.SignatureValidator; -import org.wildfly.channel.spi.ArtifactIdentifier; import java.io.File; import java.io.IOException; +import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -41,7 +39,6 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import static org.wildfly.channel.ChannelImpl.SIGNATURE_FILE_SUFFIX; public class ChannelSessionInitTestCase { @TempDir @@ -126,8 +123,7 @@ public void throwExceptionRequiredChannelIdNotAvailableAndNotAbleToResolve() thr .build(); mockManifest(resolver, baseManifest, "test.channels:base-manifest:1.0.0"); - when(resolver.resolveArtifact("test.channels", "i-dont-exist", ChannelManifest.EXTENSION, - ChannelManifest.CLASSIFIER, "1.0.0")) + when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test.channels", "i-dont-exist", "1.0.0")))) .thenThrow(ArtifactTransferException.class); List channels = List.of(new Channel.Builder() @@ -405,10 +401,6 @@ public void getVersionOfResolvedBlocklist() throws Exception { mockManifest(resolver, requiredManifest, "test.channels:base-manifest:1.0.0"); when(resolver.getAllVersions("test.channels", "blocklist", BlocklistCoordinate.EXTENSION, BlocklistCoordinate.CLASSIFIER)) .thenReturn(Set.of("1.0.0")); - final File testFile = tempDir.resolve("test.yaml").toFile(); - Files.writeString(testFile.toPath(), "schemaVersion: 1.0.0"); - when(resolver.resolveArtifact("test.channels", "blocklist", BlocklistCoordinate.EXTENSION, BlocklistCoordinate.CLASSIFIER, "1.0.0")) - .thenReturn(testFile); final List channels = List.of(new Channel.Builder() .setName("channel one") @@ -426,102 +418,21 @@ public void getVersionOfResolvedBlocklist() throws Exception { } } - @Test - public void mavenManifestWithoutSignatureCausesError() throws Exception { - MavenVersionsResolver.Factory factory = mock(MavenVersionsResolver.Factory.class); - MavenVersionsResolver resolver = mock(MavenVersionsResolver.class); - when(factory.create(any())).thenReturn(resolver); - - final ChannelManifest baseManifest = new ManifestBuilder() - .setId("manifest-one") - .build(); - mockManifest(resolver, baseManifest, "test.channels:base-manifest:1.0.0"); - - // two channels providing base- and required- manifests - List channels = List.of(new Channel.Builder() - .setName("channel one") - .addRepository("test", "test") - .setManifestCoordinate("test.channels", "base-manifest", "1.0.0") - .setGpgCheck(true) - .build() - ); - - when(resolver.resolveArtifact("test.channels", "base-manifest", - ChannelManifest.EXTENSION + SIGNATURE_FILE_SUFFIX, ChannelManifest.CLASSIFIER, "1.0.0")) - .thenThrow(ArtifactTransferException.class); - assertThrows(SignatureValidator.SignatureException.class, () -> new ChannelSession(channels, factory)); - } - - @Test - public void urlManifestWithoutSignatureCausesError() throws Exception { - MavenVersionsResolver.Factory factory = mock(MavenVersionsResolver.Factory.class); - MavenVersionsResolver resolver = mock(MavenVersionsResolver.class); - when(factory.create(any())).thenReturn(resolver); - - final ChannelManifest baseManifest = new ManifestBuilder() - .setId("manifest-one") - .build(); - final Path manifestFile = tempDir.resolve("test-manifest.yaml"); - Files.writeString(manifestFile, ChannelManifestMapper.toYaml(baseManifest)); - - // two channels providing base- and required- manifests - List channels = List.of(new Channel.Builder() - .setName("channel one") - .addRepository("test", "test") - .setManifestCoordinate(new ChannelManifestCoordinate(manifestFile.toUri().toURL())) - .setGpgCheck(true) - .build() - ); - - when(resolver.resolveArtifact("test.channels", "base-manifest", - ChannelManifest.EXTENSION + SIGNATURE_FILE_SUFFIX, ChannelManifest.CLASSIFIER, "1.0.0")) - .thenThrow(ArtifactTransferException.class); - assertThrows(InvalidChannelMetadataException.class, () -> new ChannelSession(channels, factory)); - } - - @Test - public void invalidSignatureCausesError() throws Exception { - MavenVersionsResolver.Factory factory = mock(MavenVersionsResolver.Factory.class); - MavenVersionsResolver resolver = mock(MavenVersionsResolver.class); - final SignatureValidator signatureValidator = mock(SignatureValidator.class); - when(factory.create(any())).thenReturn(resolver); - - final ChannelManifest baseManifest = new ManifestBuilder() - .setId("manifest-one") - .build(); - mockManifest(resolver, baseManifest, "test.channels:base-manifest:1.0.0"); - - // two channels providing base- and required- manifests - List channels = List.of(new Channel.Builder() - .setName("channel one") - .addRepository("test", "test") - .setManifestCoordinate("test.channels", "base-manifest", "1.0.0") - .setGpgCheck(true) - .build() - ); - - when(resolver.resolveArtifact("test.channels", "base-manifest", - ChannelManifest.EXTENSION + SIGNATURE_FILE_SUFFIX, ChannelManifest.CLASSIFIER, "1.0.0")) - .thenReturn(tempDir.resolve("test-manifest.yaml.asc").toFile()); - when(signatureValidator.validateSignature(any(), any(), any(), any())).thenReturn(SignatureResult.invalid(mock(ArtifactIdentifier.class))); - assertThrows(SignatureValidator.SignatureException.class, () -> new ChannelSession(channels, factory)); - } - private void mockManifest(MavenVersionsResolver resolver, ChannelManifest manifest, String gav) throws IOException { mockManifest(resolver, ChannelManifestMapper.toYaml(manifest), gav); } - private void mockManifest(MavenVersionsResolver resolver, String manifest, String gavString) throws IOException { + private void mockManifest(MavenVersionsResolver resolver, String manifest, String gav) throws IOException { Path manifestFile = tempDir.resolve("manifest_" + RandomUtils.nextInt() + ".yaml"); Files.writeString(manifestFile, manifest); + mockManifest(resolver, manifestFile.toUri().toURL(), gav); + } + + private void mockManifest(MavenVersionsResolver resolver, URL manifestUrl, String gavString) throws IOException { final String[] splitGav = gavString.split(":"); final MavenCoordinate gav = new MavenCoordinate(splitGav[0], splitGav[1], splitGav.length == 3 ? splitGav[2] : null); - - when(resolver.getAllVersions(gav.getGroupId(), gav.getArtifactId(), ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER)) - .thenReturn(Set.of(splitGav.length == 3 ? gav.getVersion() : "1.0.0")); - when(resolver.resolveArtifact(gav.getGroupId(), gav.getArtifactId(), ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER, - splitGav.length == 3 ? gav.getVersion() : "1.0.0")) - .thenReturn(manifestFile.toFile()); + when(resolver.resolveChannelMetadata(eq(List.of(ChannelManifestCoordinate.create(null, gav))))) + .thenReturn(List.of(manifestUrl)); } } diff --git a/core/src/test/java/org/wildfly/channel/ChannelSessionTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelSessionTestCase.java index ee45f64d..3ab970d2 100644 --- a/core/src/test/java/org/wildfly/channel/ChannelSessionTestCase.java +++ b/core/src/test/java/org/wildfly/channel/ChannelSessionTestCase.java @@ -48,6 +48,8 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentMatcher; +import org.mockito.ArgumentMatchers; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; import org.wildfly.channel.spi.MavenVersionsResolver; @@ -79,7 +81,7 @@ public void testFindLatestMavenArtifactVersion() throws Exception { assertEquals("25.0.0.Final", version.getVersion()); } - verify(resolver, times(2)).close(); + verify(resolver, times(1)).close(); } public static List mockChannel(MavenVersionsResolver resolver, Path tempDir, String... manifests) throws IOException { @@ -97,8 +99,8 @@ public static List mockChannel(MavenVersionsResolver resolver, Path tem String manifest = manifests[i]; Path manifestFile = Files.writeString(tempDir.resolve("manifest" + i +".yaml"), manifest); - when(resolver.resolveArtifact("org.channels", "channel" + i, ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER, "1.0.0")) - .thenReturn(manifestFile.toFile()); + when(resolver.resolveChannelMetadata(eq(List.of(new ChannelManifestCoordinate("org.channels", "channel" + i, "1.0.0"))))) + .thenReturn(List.of(manifestFile.toUri().toURL())); } return channels; } @@ -128,7 +130,7 @@ public void testFindLatestMavenArtifactVersionThrowsUnresolvedMavenArtifactExcep } } - verify(resolver, times(2)).close(); + verify(resolver, times(1)).close(); } @Test @@ -163,7 +165,7 @@ public void testResolveLatestMavenArtifact() throws Exception { assertEquals("channel-0", artifact.getChannelName().get()); } - verify(resolver, times(2)).close(); + verify(resolver, times(1)).close(); } @Test @@ -192,7 +194,7 @@ public void testResolveLatestMavenArtifactThrowUnresolvedMavenArtifactException( } } - verify(resolver, times(2)).close(); + verify(resolver, times(1)).close(); } @Test @@ -231,7 +233,7 @@ public void testResolveDirectMavenArtifact() throws Exception { assertEquals(Optional.empty(), artifact.getChannelName(), "The channel name should be null when resolving version directly"); } - verify(resolver, times(2)).close(); + verify(resolver, times(1)).close(); } @Test @@ -280,7 +282,7 @@ public void testResolveMavenArtifactsFromOneChannel() throws Exception { assertEquals("25.0.0.Final", stream.get().getVersion()); } - verify(resolver, times(2)).close(); + verify(resolver, times(1)).close(); } @Test @@ -342,7 +344,7 @@ public List answer(InvocationOnMock invocationOnMock) throws Throwable { assertEquals("25.0.0.Final", stream.get().getVersion()); } - verify(resolver, times(3)).close(); + verify(resolver, times(2)).close(); } @Test @@ -373,8 +375,8 @@ public void testResolveDirectMavenArtifacts() throws Exception { assertNotNull(resolved); final List expected = asList( - new MavenArtifact("org.foo", "foo", null, null, "25.0.0.Final", resolvedArtifactFile1), - new MavenArtifact("org.bar", "bar", null, null, "26.0.0.Final", resolvedArtifactFile2) + new MavenArtifact("org.foo", "foo", null, null, "25.0.0.Final", resolvedArtifactFile1, "channel-0"), + new MavenArtifact("org.bar", "bar", null, null, "26.0.0.Final", resolvedArtifactFile2, "channel-0") ); assertContainsAll(expected, resolved); @@ -386,7 +388,100 @@ public void testResolveDirectMavenArtifacts() throws Exception { assertEquals("25.0.0.Final", stream.get().getVersion()); } - verify(resolver, times(2)).close(); + verify(resolver, times(1)).close(); + } + + @Test + public void testResolveDirectMavenArtifactsFromTwoChannels() throws Exception { + String manifest = "schemaVersion: " + CURRENT_SCHEMA_VERSION + "\n" + + "streams:\n" + + " - groupId: org.not\n" + + " artifactId: used\n" + + " version: \"1.0.0.Final\""; + + /* + * create two resolvers. The first one will be used by the first channel, the other by the second channel + * Each resolver is only able to resolve one artifact and throws error when searching for both artifacts + */ + MavenVersionsResolver.Factory factory = mock(MavenVersionsResolver.Factory.class); + MavenVersionsResolver resolver1 = mock(MavenVersionsResolver.class); + MavenVersionsResolver resolver2 = mock(MavenVersionsResolver.class); + File resolvedArtifactFile1 = mock(File.class); + File resolvedArtifactFile2 = mock(File.class); + + when(factory.create(any())).thenAnswer(inv->{ + final Channel channel = inv.getArgument(0); + if (channel.getName().equals("channel-0")) { + return resolver1; + } else if (channel.getName().equals("channel-1")) { + return resolver2; + } else { + throw new RuntimeException("Unexpected channel " + channel.getName()); + } + }); + + final ArtifactCoordinate fooArtifact = new ArtifactCoordinate("org.foo", "foo", null, null, "25.0.0.Final"); + final ArtifactCoordinate barArtifact = new ArtifactCoordinate("org.bar", "bar", null, null, "26.0.0.Final"); + final List coordinates = asList( + fooArtifact, + barArtifact); + when(resolver1.resolveArtifacts(any())).thenAnswer(inv -> { + final List coords = inv.getArgument(0); + if (coords.size() == 2) { + throw new ArtifactTransferException("", + Set.of(barArtifact), + Set.of(new Repository("test", "http://test.te")) + ); + } else if (coords.get(0).equals(fooArtifact)) { + return List.of(resolvedArtifactFile1); + } else { + throw new RuntimeException("Unexpected query " + coords); + } + }); + when(resolver2.resolveArtifacts(any())).thenAnswer(inv -> { + final List coords = inv.getArgument(0); + if (coords.size() == 2) { + throw new ArtifactTransferException("", + Set.of(fooArtifact), + Set.of(new Repository("test", "http://test.te")) + ); + } else if (coords.get(0).equals(barArtifact)) { + return List.of(resolvedArtifactFile2); + } else { + throw new RuntimeException("Unexpected query " + coords); + } + }); + + /* + * create channel session with two channels. The manifests don't matter, but set different names + */ + final List channels = mockChannel(resolver1, tempDir, manifest); + channels.add(new Channel.Builder(mockChannel(resolver2, tempDir, manifest).get(0)) + .setName("channel-1").build()); + + try (ChannelSession session = new ChannelSession(channels, factory)) { + + List resolved = session.resolveDirectMavenArtifacts(coordinates); + assertNotNull(resolved); + + final List expected = asList( + new MavenArtifact(fooArtifact.getGroupId(), fooArtifact.getArtifactId(), fooArtifact.getExtension(), + fooArtifact.getClassifier(), fooArtifact.getVersion(), resolvedArtifactFile1, "channel-0"), + new MavenArtifact(barArtifact.getGroupId(), barArtifact.getArtifactId(), barArtifact.getExtension(), + barArtifact.getClassifier(), barArtifact.getVersion(), resolvedArtifactFile2, "channel-1") + ); + assertContainsAll(expected, resolved); + + Optional stream = session.getRecordedChannel().findStreamFor("org.bar", "bar"); + assertTrue(stream.isPresent()); + assertEquals("26.0.0.Final", stream.get().getVersion()); + stream = session.getRecordedChannel().findStreamFor("org.foo", "foo"); + assertTrue(stream.isPresent()); + assertEquals("25.0.0.Final", stream.get().getVersion()); + } + + verify(resolver1, times(1)).close(); + verify(resolver2, times(1)).close(); } @Test diff --git a/core/src/test/java/org/wildfly/channel/ChannelSessionWithSignatureValidationTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelSessionWithSignatureValidationTestCase.java deleted file mode 100644 index 1a47b080..00000000 --- a/core/src/test/java/org/wildfly/channel/ChannelSessionWithSignatureValidationTestCase.java +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright 2024 Red Hat, Inc. and/or its affiliates - * and other contributors as indicated by the @author tags. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.wildfly.channel; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.wildfly.channel.ChannelImpl.SIGNATURE_FILE_SUFFIX; -import static org.wildfly.channel.ChannelManifestMapper.CURRENT_SCHEMA_VERSION; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.wildfly.channel.spi.MavenVersionsResolver; -import org.wildfly.channel.spi.SignatureResult; -import org.wildfly.channel.spi.SignatureValidator; -import org.wildfly.channel.spi.ArtifactIdentifier; - -public class ChannelSessionWithSignatureValidationTestCase { - - private static final ArtifactIdentifier.MavenResource ARTIFACT = new ArtifactIdentifier.MavenResource( - "org.wildfly", "wildfly-ee-galleon-pack", "zip", null, "25.0.1.Final"); - - private static final ArtifactIdentifier.MavenResource MANIFEST = new ArtifactIdentifier.MavenResource( - "org.channels", "test-manifest", ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER, "1.0.0"); - - @TempDir - private Path tempDir; - private MavenVersionsResolver resolver; - private SignatureValidator signatureValidator; - private MavenVersionsResolver.Factory factory; - private File resolvedArtifactFile; - private List channels; - private File signatureFile; - - @BeforeEach - public void setUp() throws Exception { - factory = mock(MavenVersionsResolver.Factory.class); - resolver = mock(MavenVersionsResolver.class); - signatureValidator = mock(SignatureValidator.class); - when(factory.create(any())).thenReturn(resolver); - - // create a manfiest with a versionPattern to test signature od the latest resolved version is downloaded - final String manifest = "schemaVersion: " + CURRENT_SCHEMA_VERSION + "\n" + - "streams:\n" + - " - groupId: org.wildfly\n" + - " artifactId: '*'\n" + - " versionPattern: '25\\.\\d+\\.\\d+.Final'"; - // create a channel requiring a gpg check - channels = List.of(new Channel.Builder() - .setName("channel-0") - .setGpgCheck(true) - .setManifestCoordinate(MANIFEST.groupId, MANIFEST.artifactId, MANIFEST.version) - .build()); - - // the resolved files need to exist otherwise we can't create streams from them - resolvedArtifactFile = tempDir.resolve("test-artifact").toFile(); - Files.createFile(resolvedArtifactFile.toPath()); - signatureFile = tempDir.resolve("test-signature.asc").toFile(); - Files.createFile(signatureFile.toPath()); - - - when(resolver.getAllVersions(ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension, ARTIFACT.classifier)) - .thenReturn(new HashSet<>(Arrays.asList("25.0.0.Final", ARTIFACT.version))); - when(resolver.resolveArtifact(ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension, ARTIFACT.classifier, ARTIFACT.version)) - .thenReturn(resolvedArtifactFile); - - - Path manifestFile = Files.writeString(tempDir.resolve("manifest.yaml"), manifest); - when(resolver.resolveArtifact(MANIFEST.groupId, MANIFEST.artifactId, MANIFEST.extension, MANIFEST.classifier, MANIFEST.version)) - .thenReturn(manifestFile.toFile()); - when(resolver.resolveArtifact(MANIFEST.groupId, MANIFEST.artifactId, - MANIFEST.extension + SIGNATURE_FILE_SUFFIX, MANIFEST.classifier, MANIFEST.version)) - .thenReturn(signatureFile); - } - - @Test - public void artifactWithCorrectSignatureIsValidated() throws Exception { - // return signature when resolving it from Maven repository - when(resolver.resolveArtifact(ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension + SIGNATURE_FILE_SUFFIX, - ARTIFACT.classifier, ARTIFACT.version)) - .thenReturn(signatureFile); - // accept all the validation requests - when(signatureValidator.validateSignature(any(), - any(), any(), any())).thenReturn(SignatureResult.ok()); - - - try (ChannelSession session = new ChannelSession(channels, factory, signatureValidator)) { - MavenArtifact artifact = session.resolveMavenArtifact( - ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension, ARTIFACT.classifier, null); - assertNotNull(artifact); - - assertEquals(ARTIFACT.groupId, artifact.getGroupId()); - assertEquals(ARTIFACT.artifactId, artifact.getArtifactId()); - assertEquals(ARTIFACT.extension, artifact.getExtension()); - assertNull(artifact.getClassifier()); - assertEquals(ARTIFACT.version, artifact.getVersion()); - assertEquals(resolvedArtifactFile, artifact.getFile()); - assertEquals("channel-0", artifact.getChannelName().get()); - } - - // validateSignature should have been called for the manifest and the artifact - verify(signatureValidator, times(2)).validateSignature(any(), any(), any(), any()); - } - - @Test - public void artifactWithoutSignatureIsRejected() throws Exception { - // simulate situation where the signature file does not exist in the repository - when(resolver.resolveArtifact(ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension + SIGNATURE_FILE_SUFFIX, - ARTIFACT.classifier, ARTIFACT.version)) - .thenThrow(ArtifactTransferException.class); - // accept all the validation requests - when(signatureValidator.validateSignature(any(), - any(), any(), any())).thenReturn(SignatureResult.ok()); - - try (ChannelSession session = new ChannelSession(channels, factory, signatureValidator)) { - assertThrows(SignatureValidator.SignatureException.class, () -> session.resolveMavenArtifact( - ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension, ARTIFACT.classifier, null)); - } - - // validateSignature should have been called for the manifest only - verify(signatureValidator, times(1)).validateSignature(any(), any(), any(), any()); - } - - @Test - public void failedSignatureValidationThrowsException() throws Exception { - // return signature when resolving it from Maven repository - when(resolver.resolveArtifact(ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension + SIGNATURE_FILE_SUFFIX, - ARTIFACT.classifier, ARTIFACT.version)) - .thenReturn(signatureFile); - // simulate a valid signature of the channel manifest, and invalid signature of the artifact - when(signatureValidator.validateSignature(eq(new ArtifactIdentifier.MavenResource( - MANIFEST.groupId, MANIFEST.artifactId, MANIFEST.extension, MANIFEST.classifier, MANIFEST.version)), - any(), any(), any())).thenReturn(SignatureResult.ok()); - when(signatureValidator.validateSignature(eq(ARTIFACT), - any(), any(), any())).thenReturn(SignatureResult.invalid(ARTIFACT)); - - - try (ChannelSession session = new ChannelSession(channels, factory, signatureValidator)) { - assertThrows(SignatureValidator.SignatureException.class, () -> session.resolveMavenArtifact("org.wildfly", - "wildfly-ee-galleon-pack", "zip", null, "25.0.0.Final")); - } - - // validateSignature should have been called for the manifest and the artifact - verify(signatureValidator, times(2)).validateSignature(any(), any(), any(), any()); - } -} diff --git a/core/src/test/java/org/wildfly/channel/ChannelWithBlocklistTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelWithBlocklistTestCase.java index 6e0fc770..edd915b6 100644 --- a/core/src/test/java/org/wildfly/channel/ChannelWithBlocklistTestCase.java +++ b/core/src/test/java/org/wildfly/channel/ChannelWithBlocklistTestCase.java @@ -17,7 +17,6 @@ package org.wildfly.channel; import java.io.File; -import java.net.URISyntaxException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -81,8 +80,10 @@ public void testFindLatestMavenArtifactVersion() throws Exception { " - groupId: org.wildfly\n" + " artifactId: wildfly-ee-galleon-pack\n" + " versionPattern: .*"); - mockManifest(resolver, "test", "test.manifest", "manifest.yaml"); - mockBlocklist(resolver, "channels/test-blocklist.yaml", "org.wildfly", "wildfly-blocklist", null); + when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test", "test.manifest", "1.0.0")))) + .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); + + mockBlocklistResolution(resolver, "channels/test-blocklist.yaml"); when(factory.create(any())).thenReturn(resolver); when(resolver.getAllVersions("org.wildfly", "wildfly-ee-galleon-pack", null, null)) @@ -93,7 +94,7 @@ public void testFindLatestMavenArtifactVersion() throws Exception { assertEquals("25.0.0.Final", version.getVersion()); } - verify(resolver, times(2)).close(); + verify(resolver, times(1)).close(); } @Test @@ -124,10 +125,13 @@ public void testFindLatestMavenArtifactVersionBlocklistDoesntExist() throws Exce " - groupId: org.wildfly\n" + " artifactId: '*'\n" + " versionPattern: '25\\.\\d+\\.\\d+.Final'"); - mockManifest(resolver, "test", "test.manifest", "manifest.yaml"); + when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test", "test.manifest", "1.0.0")))) + .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); + + when(resolver.resolveChannelMetadata(List.of(new BlocklistCoordinate("org.wildfly", "wildfly-blocklist")))) + .thenReturn(Collections.emptyList()); when(factory.create(any())).thenReturn(resolver); - // return empty version list when blocklist is queried when(resolver.getAllVersions("org.wildfly", "wildfly-blocklist", BlocklistCoordinate.EXTENSION, BlocklistCoordinate.CLASSIFIER)).thenReturn(Collections.emptySet()); when(resolver.getAllVersions("org.wildfly", "wildfly-ee-galleon-pack", null, null)) @@ -138,7 +142,7 @@ public void testFindLatestMavenArtifactVersionBlocklistDoesntExist() throws Exce assertEquals("25.0.1.Final", version.getVersion()); } - verify(resolver, times(2)).close(); + verify(resolver, times(1)).close(); } @Test @@ -169,8 +173,10 @@ public void testFindLatestMavenArtifactVersionWithWildcardBlocklist() throws Exc " - groupId: org.wildfly\n" + " artifactId: '*'\n" + " versionPattern: '25\\.\\d+\\.\\d+.Final'"); - mockManifest(resolver, "test", "test.manifest", "manifest.yaml"); - mockBlocklist(resolver, "channels/test-blocklist-with-wildcards.yaml", "org.wildfly", "wildfly-blocklist", null); + when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test", "test.manifest", "1.0.0")))) + .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); + + mockBlocklistResolution(resolver, "channels/test-blocklist-with-wildcards.yaml"); when(factory.create(any())).thenReturn(resolver); when(resolver.getAllVersions("org.wildfly", "wildfly-ee-galleon-pack", null, null)) @@ -181,7 +187,7 @@ public void testFindLatestMavenArtifactVersionWithWildcardBlocklist() throws Exc assertEquals("25.0.0.Final", version.getVersion()); } - verify(resolver, times(2)).close(); + verify(resolver, times(1)).close(); } @Test @@ -212,9 +218,10 @@ public void testFindLatestMavenArtifactVersionBlocklistsAllVersionsException() t " - groupId: org.wildfly\n" + " artifactId: '*'\n" + " versionPattern: '25\\.\\d+\\.\\d+.Final'"); - mockManifest(resolver, "test", "test.manifest", "manifest.yaml"); + when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test", "test.manifest", "1.0.0")))) + .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); - mockBlocklist(resolver, "channels/test-blocklist.yaml", "org.wildfly", "wildfly-blocklist", null); + mockBlocklistResolution(resolver, "channels/test-blocklist.yaml"); when(factory.create(any())).thenReturn(resolver); when(resolver.getAllVersions("org.wildfly", "wildfly-ee-galleon-pack", null, null)).thenReturn(new HashSet<>(singleton("25.0.1.Final"))); @@ -228,7 +235,7 @@ public void testFindLatestMavenArtifactVersionBlocklistsAllVersionsException() t } } - verify(resolver, times(2)).close(); + verify(resolver, times(1)).close(); } @Test @@ -259,9 +266,10 @@ public void testResolveLatestMavenArtifact() throws Exception { " - groupId: org.wildfly\n" + " artifactId: '*'\n" + " versionPattern: '25\\.\\d+\\.\\d+.Final'"); - mockManifest(resolver, "test", "test.manifest", "manifest.yaml"); + when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test", "test.manifest", "1.0.0")))) + .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); - mockBlocklist(resolver, "channels/test-blocklist.yaml", "org.wildfly", "wildfly-blocklist", null); + mockBlocklistResolution(resolver, "channels/test-blocklist.yaml"); File resolvedArtifactFile = mock(File.class); @@ -282,7 +290,7 @@ public void testResolveLatestMavenArtifact() throws Exception { assertEquals(resolvedArtifactFile, artifact.getFile()); } - verify(resolver, times(2)).close(); + verify(resolver, times(1)).close(); } @Test @@ -313,8 +321,10 @@ public void testResolveLatestMavenArtifactThrowUnresolvedMavenArtifactException( " - groupId: org.wildfly\n" + " artifactId: '*'\n" + " versionPattern: '25\\.\\d+\\.\\d+.Final'"); - mockManifest(resolver, "test", "test.manifest", "manifest.yaml"); - mockBlocklist(resolver, "channels/test-blocklist.yaml", "org.wildfly", "wildfly-blocklist", null); + when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test", "test.manifest", "1.0.0")))) + .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); + + mockBlocklistResolution(resolver, "channels/test-blocklist.yaml"); when(factory.create(any())).thenReturn(resolver); when(resolver.getAllVersions("org.wildfly", "wildfly-ee-galleon-pack", null, null)).thenReturn(new HashSet<>(Set.of("25.0.1.Final","26.0.0.Final"))); @@ -328,28 +338,14 @@ public void testResolveLatestMavenArtifactThrowUnresolvedMavenArtifactException( } } - verify(resolver, times(2)).close(); - } - - private void mockBlocklist(MavenVersionsResolver resolver, String blocklistFileLocation, - String groupId, String artifactId, String version) throws URISyntaxException { - - if (version == null) { - when(resolver.getAllVersions(groupId, artifactId, BlocklistCoordinate.EXTENSION, - BlocklistCoordinate.CLASSIFIER)) - .thenReturn(Set.of("1.0.0")); - version = "1.0.0"; - } - when(resolver.resolveArtifact(groupId, artifactId, BlocklistCoordinate.EXTENSION, - BlocklistCoordinate.CLASSIFIER, version)) - .thenReturn(new File(this.getClass().getClassLoader().getResource(blocklistFileLocation).toURI())); + verify(resolver, times(1)).close(); } - private void mockManifest(MavenVersionsResolver resolver, String groupId, String artifactId, String manifestFileName) { - when(resolver.getAllVersions(groupId, artifactId, ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER)) + private void mockBlocklistResolution(MavenVersionsResolver resolver, String fileName) { + when(resolver.getAllVersions("org.wildfly", "wildfly-blocklist", BlocklistCoordinate.EXTENSION, BlocklistCoordinate.CLASSIFIER)) .thenReturn(Set.of("1.0.0")); - when(resolver.resolveArtifact(groupId, artifactId, ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER, "1.0.0")) - .thenReturn(tempDir.resolve(manifestFileName).toFile()); + when(resolver.resolveChannelMetadata(List.of(new BlocklistCoordinate("org.wildfly", "wildfly-blocklist", "1.0.0")))) + .thenReturn(List.of(this.getClass().getClassLoader().getResource(fileName))); } @Test @@ -383,9 +379,10 @@ public void testResolveMavenArtifactsFromOneChannel() throws Exception { " - groupId: org.wildfly\n" + " artifactId: wildfly-cli\n" + " version: \"26.0.0.Final\""); - mockManifest(resolver, "test", "test.manifest", "manifest.yaml"); + when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test", "test.manifest", "1.0.0")))) + .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); - mockBlocklist(resolver, "channels/test-blocklist.yaml", "org.wildfly", "wildfly-blocklist", null); + mockBlocklistResolution(resolver, "channels/test-blocklist.yaml"); File resolvedArtifactFile1 = mock(File.class); File resolvedArtifactFile2 = mock(File.class); @@ -420,7 +417,7 @@ public void testResolveMavenArtifactsFromOneChannel() throws Exception { assertEquals("26.0.0.Final", stream.get().getVersion()); } - verify(resolver, times(2)).close(); + verify(resolver, times(1)).close(); } @Test @@ -447,8 +444,8 @@ public void testFindLatestMavenArtifactVersionInRequiredChannel() throws Excepti " - groupId: org.wildfly\n" + " artifactId: wildfly-ee-galleon-pack\n" + " versionPattern: \".*\""); - mockManifest(resolver, "org.test", "required-manifest", - "required-manifest.yaml"); + when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("org.test", "required-manifest", "1.0.0")))) + .thenReturn(List.of(tempDir.resolve("required-manifest.yaml").toUri().toURL())); Files.writeString(tempDir.resolve("manifest.yaml"), "schemaVersion: " + ChannelManifestMapper.CURRENT_SCHEMA_VERSION + "\n" + @@ -457,8 +454,11 @@ public void testFindLatestMavenArtifactVersionInRequiredChannel() throws Excepti " - groupId: org.wildfly\n" + " artifactId: wildfly-ee-galleon-pack\n" + " versionPattern: \".*\""); - mockManifest(resolver, "org.test", "base-manifest", "manifest.yaml"); - mockBlocklist(resolver, "channels/test-blocklist.yaml", "org.wildfly", "wildfly-blocklist", "1.2.3"); + when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("org.test", "base-manifest", "1.0.0")))) + .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); + + when(resolver.resolveChannelMetadata(List.of(new BlocklistCoordinate("org.wildfly", "wildfly-blocklist", "1.2.3")))) + .thenReturn(List.of(this.getClass().getClassLoader().getResource("channels/test-blocklist.yaml"))); when(factory.create(any())).thenReturn(resolver); when(resolver.getAllVersions("org.wildfly", "wildfly-ee-galleon-pack", null, null)) @@ -469,7 +469,7 @@ public void testFindLatestMavenArtifactVersionInRequiredChannel() throws Excepti assertEquals("25.0.0.Final", version.getVersion()); } - verify(resolver, times(3)).close(); + verify(resolver, times(2)).close(); } @Test @@ -500,8 +500,10 @@ public void testChannelWithInvalidBlacklist() throws Exception { " - groupId: org.wildfly\n" + " artifactId: '*'\n" + " versionPattern: '25\\.\\d+\\.\\d+.Final'"); - mockManifest(resolver, "test", "test.manifest", "manifest.yaml"); - mockBlocklist(resolver, "channels/invalid-blocklist.yaml", "org.wildfly", "wildfly-blocklist", null); + when(resolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test", "test.manifest", "1.0.0")))) + .thenReturn(List.of(tempDir.resolve("manifest.yaml").toUri().toURL())); + + mockBlocklistResolution(resolver, "channels/invalid-blocklist.yaml"); when(factory.create(any())).thenReturn(resolver); diff --git a/core/src/test/java/org/wildfly/channel/ChannelWithRequirementsTestCase.java b/core/src/test/java/org/wildfly/channel/ChannelWithRequirementsTestCase.java index bace5645..a1b95244 100644 --- a/core/src/test/java/org/wildfly/channel/ChannelWithRequirementsTestCase.java +++ b/core/src/test/java/org/wildfly/channel/ChannelWithRequirementsTestCase.java @@ -56,20 +56,21 @@ public void testChannelWhichRequiresAnotherChannel() throws Exception { MavenVersionsResolver resolver = mock(MavenVersionsResolver.class); ClassLoader tccl = Thread.currentThread().getContextClassLoader(); + File resolvedArtifactFile = mock(File.class); URL resolvedRequiredManifestURL = tccl.getResource("channels/required-manifest.yaml"); - File resolvedArtifactFile = new File(resolvedRequiredManifestURL.toURI()); when(factory.create(any())) .thenReturn(resolver); when(resolver.getAllVersions("test.channels", "required-manifest", "yaml", "manifest")) .thenReturn(new HashSet<>(Arrays.asList("1", "2", "3"))); - when(resolver.resolveArtifact("test.channels", "required-manifest", "yaml", "manifest", "3")) + when(resolver.resolveArtifact("org.example", "required-manifest", "yaml", "manifest", "3")) .thenReturn(resolvedArtifactFile); when(resolver.getAllVersions("org.example", "foo-bar", null, null)) .thenReturn(new HashSet<>(Arrays.asList("1.0.0.Final, 1.1.0.Final", "1.2.0.Final"))); when(resolver.resolveArtifact("org.example", "foo-bar", null, null, "1.2.0.Final")) .thenReturn(resolvedArtifactFile); + when(resolver.resolveChannelMetadata(any())).thenReturn(List.of(resolvedRequiredManifestURL)); String baseManifest = "schemaVersion: " + ChannelManifestMapper.CURRENT_SCHEMA_VERSION + "\n" + "name: My manifest\n" + @@ -105,11 +106,6 @@ public void testChannelWhichRequiresAnotherChannel() throws Exception { } } - private static void mockManifest(MavenVersionsResolver resolver, URL resolvedRequiredManifestURL) throws URISyntaxException { - when(resolver.resolveArtifact(any(), any(), eq(ChannelManifest.EXTENSION), eq(ChannelManifest.CLASSIFIER), any())) - .thenReturn(new File(resolvedRequiredManifestURL.toURI())); - } - /** * Test that specific version of required channel is used when required */ @@ -128,7 +124,7 @@ public void testChannelWhichRequiresAnotherVersionedChannel() throws Exception { .thenReturn(resolver); when(resolver.resolveArtifact("org.example", "foo-bar", null, null, "1.2.0.Final")) .thenReturn(resolvedArtifactFile); - mockManifest(resolver, resolvedRequiredManifestURL); + when(resolver.resolveChannelMetadata(any())).thenReturn(List.of(resolvedRequiredManifestURL)); String baseManifest = "schemaVersion: " + ChannelManifestMapper.CURRENT_SCHEMA_VERSION + "\n" + "name: My manifest\n" + @@ -196,7 +192,8 @@ public void testRequiringChannelOverridesStreamFromRequiredChannel() throws Exce .thenReturn(resolvedArtifactFile120Final); when(resolver.resolveArtifact("org.example", "foo-bar", null, null, "2.0.0.Final")) .thenReturn(resolvedArtifactFile200Final); - mockManifest(resolver, resolvedRequiredManifestURL, "test.channels:required-manifest:1.0.0"); + when(resolver.resolveChannelMetadata(eq(List.of(new ChannelManifestCoordinate("test.channels", "required-manifest", "1.0.0"))))) + .thenReturn(List.of(resolvedRequiredManifestURL)); // The requiring channel requires newer version of foo-bar artifact List channels = ChannelMapper.fromString( @@ -568,19 +565,11 @@ public void testChannelMultipleRequirements() throws Exception { } } - private void mockManifest(MavenVersionsResolver resolver, String manifest, String gavString) throws IOException { + private void mockManifest(MavenVersionsResolver resolver, String manifest, String gav) throws IOException { Path manifestFile = tempDir.resolve("manifest_" + RandomUtils.nextInt() + ".yaml"); Files.writeString(manifestFile, manifest); - final String[] splitGav = gavString.split(":"); - final MavenCoordinate gav = new MavenCoordinate(splitGav[0], splitGav[1], splitGav.length == 3 ? splitGav[2] : null); - if (gav.getVersion() == null) { - when(resolver.getAllVersions(gav.getGroupId(), gav.getArtifactId(), ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER)) - .thenReturn(Set.of("1.0.0")); - } - when(resolver.resolveArtifact(gav.getGroupId(), gav.getArtifactId(), ChannelManifest.EXTENSION, - ChannelManifest.CLASSIFIER, gav.getVersion() == null ? "1.0.0" : gav.getVersion())) - .thenReturn(manifestFile.toFile()); + mockManifest(resolver, manifestFile.toUri().toURL(), gav); } @Test @@ -629,15 +618,10 @@ public void testRequiredChannelIgnoresNoStreamStrategy() throws Exception { } } - private void mockManifest(MavenVersionsResolver resolver, URL manifestUrl, String gavString) throws URISyntaxException { + private void mockManifest(MavenVersionsResolver resolver, URL manifestUrl, String gavString) throws IOException { final String[] splitGav = gavString.split(":"); final MavenCoordinate gav = new MavenCoordinate(splitGav[0], splitGav[1], splitGav.length == 3 ? splitGav[2] : null); - if (gav.getVersion() == null) { - when(resolver.getAllVersions(gav.getGroupId(), gav.getArtifactId(), ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER)) - .thenReturn(Set.of("1.0.0")); - } - when(resolver.resolveArtifact(gav.getGroupId(), gav.getArtifactId(), ChannelManifest.EXTENSION, - ChannelManifest.CLASSIFIER, gav.getVersion() == null ? "1.0.0" : gav.getVersion())) - .thenReturn(new File(manifestUrl.toURI())); + when(resolver.resolveChannelMetadata(eq(List.of(ChannelManifestCoordinate.create(null, gav))))) + .thenReturn(List.of(manifestUrl)); } } diff --git a/core/src/test/java/org/wildfly/channel/SignedVersionResolverWrapperTest.java b/core/src/test/java/org/wildfly/channel/SignedVersionResolverWrapperTest.java new file mode 100644 index 00000000..d781b6c8 --- /dev/null +++ b/core/src/test/java/org/wildfly/channel/SignedVersionResolverWrapperTest.java @@ -0,0 +1,186 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.wildfly.channel; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.wildfly.channel.SignedVersionResolverWrapper.SIGNATURE_FILE_SUFFIX; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; + +import org.apache.commons.lang3.RandomUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.wildfly.channel.spi.ArtifactIdentifier; +import org.wildfly.channel.spi.MavenVersionsResolver; +import org.wildfly.channel.spi.SignatureResult; +import org.wildfly.channel.spi.SignatureValidator; + +class SignedVersionResolverWrapperTest { + + private static final ArtifactIdentifier.MavenResource ARTIFACT = new ArtifactIdentifier.MavenResource( + "org.wildfly", "wildfly-ee-galleon-pack", "zip", null, "25.0.1.Final"); + + @TempDir + private Path tempDir; + private MavenVersionsResolver resolver; + private SignatureValidator signatureValidator; + private SignedVersionResolverWrapper signedResolver; + + private File signatureFile; + private File resolvedArtifactFile; + + @BeforeEach + public void setUp() throws Exception { + resolver = mock(MavenVersionsResolver.class); + signatureValidator = mock(SignatureValidator.class); + signedResolver = new SignedVersionResolverWrapper(resolver, List.of(new Repository("test", "test")), + signatureValidator, Collections.emptyList()); + + MavenVersionsResolver.Factory factory = mock(MavenVersionsResolver.Factory.class); + when(factory.create(any())).thenReturn(resolver); + + signatureFile = tempDir.resolve("test-signature.asc").toFile(); + Files.createFile(signatureFile.toPath()); + + resolvedArtifactFile = tempDir.resolve("test-artifact").toFile(); + Files.createFile(resolvedArtifactFile.toPath()); + + when(factory.create(any())) + .thenReturn(resolver); + when(resolver.resolveArtifact("org.example", "foo-bar", null, null, "1.2.0.Final")) + .thenReturn(resolvedArtifactFile); + when(resolver.resolveArtifact(ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension, ARTIFACT.classifier, ARTIFACT.version)) + .thenReturn(resolvedArtifactFile); + } + + @Test + public void invalidSignatureCausesError() throws Exception { + final ChannelManifest baseManifest = new ManifestBuilder() + .setId("manifest-one") + .build(); + mockManifest(resolver, baseManifest, "test.channels:base-manifest:1.0.0"); + + Files.createFile(tempDir.resolve("test-manifest.yaml.asc")); + when(resolver.resolveArtifact("test.channels", "base-manifest", + ChannelManifest.EXTENSION + SIGNATURE_FILE_SUFFIX, ChannelManifest.CLASSIFIER, "1.0.0")) + .thenReturn(tempDir.resolve("test-manifest.yaml.asc").toFile()); + when(signatureValidator.validateSignature(any(), any(), any(), any())).thenReturn(SignatureResult.invalid(mock(ArtifactIdentifier.class))); + assertThrows(SignatureValidator.SignatureException.class, + () -> signedResolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test.channels", "base-manifest", "1.0.0")))); + } + + @Test + public void mvnManifestWithoutSignatureCausesError() throws Exception { + final ChannelManifest baseManifest = new ManifestBuilder() + .setId("manifest-one") + .build(); + final Path manifestFile = tempDir.resolve("test-manifest.yaml"); + Files.writeString(manifestFile, ChannelManifestMapper.toYaml(baseManifest)); + + when(resolver.resolveArtifact("test.channels", "base-manifest", + ChannelManifest.EXTENSION + SIGNATURE_FILE_SUFFIX, ChannelManifest.CLASSIFIER, "1.0.0")) + .thenThrow(ArtifactTransferException.class); + assertThrows(SignatureValidator.SignatureException.class, + () -> signedResolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test.channels", "base-manifest", "1.0.0")))); + } + + @Test + public void artifactWithCorrectSignatureIsValidated() throws Exception { + // return signature when resolving it from Maven repository + when(resolver.resolveArtifact(ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension + SIGNATURE_FILE_SUFFIX, + ARTIFACT.classifier, ARTIFACT.version)) + .thenReturn(signatureFile); + // accept all the validation requests + when(signatureValidator.validateSignature(any(), + any(), any(), any())).thenReturn(SignatureResult.ok()); + + + assertEquals(resolvedArtifactFile, signedResolver.resolveArtifact(ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension, ARTIFACT.classifier, ARTIFACT.version)); + + verify(signatureValidator).validateSignature(any(), any(), any(), any()); + } + + @Test + public void artifactWithoutSignatureIsRejected() throws Exception { + // simulate situation where the signature file does not exist in the repository + when(resolver.resolveArtifact(ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension + SIGNATURE_FILE_SUFFIX, + ARTIFACT.classifier, ARTIFACT.version)) + .thenThrow(ArtifactTransferException.class); + // accept all the validation requests + when(signatureValidator.validateSignature(any(), + any(), any(), any())).thenReturn(SignatureResult.ok()); + + + assertThrows(SignatureValidator.SignatureException.class, () -> signedResolver.resolveArtifact( + ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension, ARTIFACT.classifier, ARTIFACT.version)); + + + // validateSignature should not have been called + verify(signatureValidator, never()).validateSignature(any(), any(), any(), any()); + } + + @Test + public void failedSignatureValidationThrowsException() throws Exception { + // return signature when resolving it from Maven repository + when(resolver.resolveArtifact(ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension + SIGNATURE_FILE_SUFFIX, + ARTIFACT.classifier, ARTIFACT.version)) + .thenReturn(signatureFile); + when(signatureValidator.validateSignature(eq(ARTIFACT), + any(), any(), any())).thenReturn(SignatureResult.invalid(ARTIFACT)); + + assertThrows(SignatureValidator.SignatureException.class, () -> signedResolver.resolveArtifact(ARTIFACT.groupId, + ARTIFACT.artifactId, ARTIFACT.extension, ARTIFACT.classifier, ARTIFACT.version)); + + + verify(signatureValidator).validateSignature(any(), any(), any(), any()); + } + + private void mockManifest(MavenVersionsResolver resolver, ChannelManifest manifest, String gav) throws IOException { + + mockManifest(resolver, ChannelManifestMapper.toYaml(manifest), gav); + } + + private void mockManifest(MavenVersionsResolver resolver, String manifest, String gav) throws IOException { + Path manifestFile = tempDir.resolve("manifest_" + RandomUtils.nextInt() + ".yaml"); + Files.writeString(manifestFile, manifest); + + mockManifest(resolver, manifestFile.toUri().toURL(), gav); + } + + private void mockManifest(MavenVersionsResolver resolver, URL manifestUrl, String gavString) throws IOException { + final String[] splitGav = gavString.split(":"); + final MavenCoordinate gav = new MavenCoordinate(splitGav[0], splitGav[1], splitGav.length == 3 ? splitGav[2] : null); + + when(resolver.resolveChannelMetadata(eq(List.of(ChannelManifestCoordinate.create(null, gav))))) + .thenReturn(List.of(manifestUrl)); + } + +} \ No newline at end of file diff --git a/core/src/test/java/org/wildfly/channel/mapping/ChannelTestCase.java b/core/src/test/java/org/wildfly/channel/mapping/ChannelTestCase.java index 3c21b78f..4a2d3935 100644 --- a/core/src/test/java/org/wildfly/channel/mapping/ChannelTestCase.java +++ b/core/src/test/java/org/wildfly/channel/mapping/ChannelTestCase.java @@ -71,7 +71,7 @@ public void multipleChannelsTest() throws IOException { } @Test - public void simpleChannelTest() throws IOException { + public void simpleChannelTest() throws MalformedURLException { ClassLoader tccl = Thread.currentThread().getContextClassLoader(); URL file = tccl.getResource("channels/simple-channel.yaml"); diff --git a/doc/spec.adoc b/doc/spec.adoc index fc4ade11..949dca01 100644 --- a/doc/spec.adoc +++ b/doc/spec.adoc @@ -77,7 +77,7 @@ A channel is composed of several fields: ** `maven-latest` - a version marked as `latest` in the Maven metadata ** `maven-release` - a version marked as `release` in the Maven metadata ** `none` - do not attempt to resolve versions of artifact not listed in the `streams` collection. Default value if no strategy is provided. -* Optional `gpg-check` boolean indicating if the artifacts resolved from this channel have to have a valid GPG signature. +* Optional `gpg-check` boolean indicating that during artifact resolution, this channel will verify a signature of every artifact. If the artifact signature cannot be found, or cannot be validated, the artifact will not be resolved from the channel. The channel repositories must contain a detached GPG signature paired with the artifact as described below. * Optional `gpg-urls` a list of URLs that the public GPG keys used to validate artifact signatures are resolved from. ### Manifest definition @@ -237,17 +237,17 @@ A blocklist applies only to the channel that defined it, not its required channe During artifact version resolution, a stream matching artifact GA is located in the channel. The blocklist is always checked for excluded versions, except when using `resolveDirectMavenArtifact` method. The excluded versions are removed from the set of available artifact versions before the latest remaining version matching the stream’s pattern is used to resolve the artifact. If the blocklist excludes all available artifact versions, `UnresolvedMavenArtifactException` is thrown. -### Verifing artifact PGP signatures +### Verifying artifact PGP signatures -If a channel sets value of `gpg-check` property to `true`, any artifact resolved from it (including the manifest itself) has to have a valid GPG signature. +If a channel sets value of `gpg-check` property to `true`, any artifact resolved from it (including the manifest itself) must have a valid GPG signature. -#### Verifing maven artifact signatures +#### Verifying Maven artifact signatures -Maven artifacts are expected to have their detached GPG signatures available in the channel repositories. The detached artifacts must have the same GAV as the artifact, but append ".asc" suffix to the file expension. The signature file must contain an armoured GPG signature. +Maven artifacts are expected to have their detached GPG signatures available in the channel repositories. The detached artifacts must have the same GAV as the artifact, but append ".asc" suffix to the file extension. The signature file must contain an armoured GPG signature. The signature file is resolved at the same time as the artifact. If the signature is invalid, the public key is not found, the public key is expired or revoked, the artifact must be rejected and a SignatureException must be thrown. -#### Verifing manifest signatures +#### Verifying manifest signatures If the manifest of a channel is defined as a Maven GA(V), it is treated as any other maven artifact. If it is defined as an URL, the signature file must be available at the same URL with ".asc" suffix. Alternatively, a `signature-url` element can be used to provide a location of the signature. If the signature cannot be resolved, the channel creation must fail. @@ -263,6 +263,7 @@ Implementations may provide additional means of providing keys - local stores, r * Adding ability to verify artifact signatures ** Adding `gpg-check` and `gpg-urls` fields to the Channel definition. +** Adding `signature-url` field to the Channel's manifest coordinate definition. ### Version 2.0.0 diff --git a/maven-resolver/src/main/java/org/wildfly/channel/maven/VersionResolverFactory.java b/maven-resolver/src/main/java/org/wildfly/channel/maven/VersionResolverFactory.java index 2861e0f3..17c8d7cd 100644 --- a/maven-resolver/src/main/java/org/wildfly/channel/maven/VersionResolverFactory.java +++ b/maven-resolver/src/main/java/org/wildfly/channel/maven/VersionResolverFactory.java @@ -26,8 +26,6 @@ import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; -import java.util.Collection; -import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -60,9 +58,9 @@ import org.wildfly.channel.Channel; import org.wildfly.channel.ChannelMapper; import org.wildfly.channel.ChannelMetadataCoordinate; -import org.wildfly.channel.MavenArtifact; import org.wildfly.channel.Repository; import org.wildfly.channel.NoStreamFoundException; +import org.wildfly.channel.SignedVersionResolverWrapper; import org.wildfly.channel.UnresolvedMavenArtifactException; import org.wildfly.channel.spi.MavenVersionsResolver; import org.wildfly.channel.spi.SignatureValidator; @@ -93,28 +91,44 @@ public class VersionResolverFactory implements MavenVersionsResolver.Factory { private final RepositorySystem system; private final RepositorySystemSession session; private final Function repositoryFactory; + private SignatureValidator signatureValidator; public VersionResolverFactory(RepositorySystem system, RepositorySystemSession session) { - this(system, session, DEFAULT_REPOSITORY_MAPPER); + this(system, session, null); } public VersionResolverFactory(RepositorySystem system, RepositorySystemSession session, + SignatureValidator signatureValidator) { + this(system, session, signatureValidator, DEFAULT_REPOSITORY_MAPPER); + } + + public VersionResolverFactory(RepositorySystem system, + RepositorySystemSession session, + SignatureValidator signatureValidator, Function repositoryFactory) { this.system = system; this.session = session; + this.signatureValidator = signatureValidator; this.repositoryFactory = repositoryFactory; } @Override - public MavenVersionsResolver create(Collection repositories) { - Objects.requireNonNull(repositories); + public MavenVersionsResolver create(Channel channel) { + Objects.requireNonNull(channel); - final List mvnRepositories = repositories.stream() + final List mvnRepositories = channel.getRepositories().stream() .map(repositoryFactory::apply) .collect(Collectors.toList()); - return create(mvnRepositories); + if (channel.isGpgCheck()) { + if (signatureValidator == null) { + throw new RuntimeException("The channel %s requires GPG verification, but the signature validator is not configured."); + } + return new SignedVersionResolverWrapper(create(mvnRepositories), channel.getRepositories(), signatureValidator, channel.getGpgUrls()); + } else { + return create(mvnRepositories); + } } private MavenResolverImpl create(List mvnRepositories) { diff --git a/maven-resolver/src/test/java/org/wildfly/channel/maven/VersionResolverFactoryTest.java b/maven-resolver/src/test/java/org/wildfly/channel/maven/VersionResolverFactoryTest.java index 0793f9e5..0ab75b4b 100644 --- a/maven-resolver/src/test/java/org/wildfly/channel/maven/VersionResolverFactoryTest.java +++ b/maven-resolver/src/test/java/org/wildfly/channel/maven/VersionResolverFactoryTest.java @@ -88,7 +88,7 @@ public void testResolverGetAllVersions() throws VersionRangeResolutionException when(system.resolveVersionRange(eq(session), any(VersionRangeRequest.class))).thenReturn(versionRangeResult); VersionResolverFactory factory = new VersionResolverFactory(system, session); - MavenVersionsResolver resolver = factory.create(Collections.singletonList(testRepository)); + MavenVersionsResolver resolver = factory.create(new Channel.Builder().addRepository(testRepository.getId(), testRepository.getUrl()).build()); Set allVersions = resolver.getAllVersions("org.foo", "bar", null, null); assertEquals(3, allVersions.size()); @@ -111,7 +111,7 @@ public void testResolverResolveArtifact() throws ArtifactResolutionException { when(system.resolveArtifact(eq(session), any(ArtifactRequest.class))).thenReturn(artifactResult); VersionResolverFactory factory = new VersionResolverFactory(system, session); - MavenVersionsResolver resolver = factory.create(Collections.emptyList()); + MavenVersionsResolver resolver = factory.create(new Channel()); File resolvedArtifact = resolver.resolveArtifact("org.foo", "bar", null, null, "1.0.0"); assertEquals(artifactFile, resolvedArtifact); @@ -125,7 +125,7 @@ public void testResolverCanNotResolveArtifact() throws ArtifactResolutionExcepti when(system.resolveArtifact(eq(session), any(ArtifactRequest.class))).thenThrow(ArtifactResolutionException.class); VersionResolverFactory factory = new VersionResolverFactory(system, session); - MavenVersionsResolver resolver = factory.create(Collections.emptyList()); + MavenVersionsResolver resolver = factory.create(new Channel()); Assertions.assertThrows(UnresolvedMavenArtifactException.class, () -> { resolver.resolveArtifact("org.foo", "does-not-exist", null, null, "1.0.0"); @@ -179,7 +179,7 @@ public void testResolverResolveAllArtifacts() throws ArtifactResolutionException when(system.resolveArtifacts(eq(session), any(List.class))).thenReturn(Arrays.asList(artifactResult1, artifactResult2)); VersionResolverFactory factory = new VersionResolverFactory(system, session); - MavenVersionsResolver resolver = factory.create(Collections.emptyList()); + MavenVersionsResolver resolver = factory.create(new Channel()); final List coordinates = asList( new ArtifactCoordinate("org.foo", "bar", null, null, "1.0.0"), @@ -197,7 +197,7 @@ public void testResolverResolveMetadataUsingUrl() throws ArtifactResolutionExcep RepositorySystemSession session = mock(RepositorySystemSession.class); VersionResolverFactory factory = new VersionResolverFactory(system, session); - MavenVersionsResolver resolver = factory.create(Collections.emptyList()); + MavenVersionsResolver resolver = factory.create(new Channel()); List resolvedURL = resolver.resolveChannelMetadata(List.of(new ChannelCoordinate(new URL("http://test.channel")))); assertEquals(new URL("http://test.channel"), resolvedURL.get(0)); @@ -231,7 +231,7 @@ public void testResolverResolveMetadataUsingGa() throws ArtifactResolutionExcept when(system.resolveArtifact(eq(session), artifactRequestArgumentCaptor.capture())).thenReturn(artifactResult); VersionResolverFactory factory = new VersionResolverFactory(system, session); - MavenVersionsResolver resolver = factory.create(Collections.singletonList(testRepository)); + MavenVersionsResolver resolver = factory.create(new Channel()); List resolvedURL = resolver.resolveChannelMetadata(List.of(new ChannelCoordinate("org.test", "channel"))); assertEquals(artifactFile.toURI().toURL(), resolvedURL.get(0)); @@ -253,7 +253,7 @@ public void testResolverResolveMetadataUsingGav() throws ArtifactResolutionExcep when(system.resolveArtifact(eq(session), artifactRequestArgumentCaptor.capture())).thenReturn(artifactResult); VersionResolverFactory factory = new VersionResolverFactory(system, session); - MavenVersionsResolver resolver = factory.create(Collections.emptyList()); + MavenVersionsResolver resolver = factory.create(new Channel()); List resolvedURL = resolver.resolveChannelMetadata(List.of(new ChannelCoordinate("org.test", "channel", "1.0.0"))); assertEquals(artifactFile.toURI().toURL(), resolvedURL.get(0)); @@ -265,9 +265,11 @@ public void testRepositoryFactory() throws Exception { RepositorySystem system = mock(RepositorySystem.class); RepositorySystemSession session = mock(RepositorySystemSession.class); - VersionResolverFactory factory = new VersionResolverFactory(system, session, + VersionResolverFactory factory = new VersionResolverFactory(system, session, null, r -> new RemoteRepository.Builder(r.getId(), "default", r.getUrl() + ".new").build()); - MavenVersionsResolver resolver = factory.create(List.of(new Repository("test_1", "http://test_1"))); + MavenVersionsResolver resolver = factory.create(new Channel.Builder() + .addRepository("test_1", "http://test_1") + .build()); File artifactFile = new File("test"); ArtifactResult artifactResult = new ArtifactResult(new ArtifactRequest()); @@ -312,7 +314,7 @@ public void testResolveLatestFromMetadata() throws Exception { when(system.resolveMetadata(eq(session), any())).thenReturn(List.of(result1, result2)); VersionResolverFactory factory = new VersionResolverFactory(system, session); - MavenVersionsResolver resolver = factory.create(Collections.emptyList()); + MavenVersionsResolver resolver = factory.create(new Channel()); final String res = resolver.getMetadataLatestVersion("org.foo", "bar"); @@ -332,7 +334,7 @@ public void testResolveLatestFromMetadataNoVersioning() throws Exception { when(system.resolveMetadata(eq(session), any())).thenReturn(List.of(result)); VersionResolverFactory factory = new VersionResolverFactory(system, session); - MavenVersionsResolver resolver = factory.create(Collections.emptyList()); + MavenVersionsResolver resolver = factory.create(new Channel()); Assertions.assertThrows(UnresolvedMavenArtifactException.class, () -> { resolver.getMetadataLatestVersion("org.foo", "bar"); @@ -348,7 +350,7 @@ public void testResolveLatestFromMetadataNoLatestVersion() throws Exception { when(system.resolveMetadata(eq(session), any())).thenReturn(List.of(result)); VersionResolverFactory factory = new VersionResolverFactory(system, session); - MavenVersionsResolver resolver = factory.create(Collections.emptyList()); + MavenVersionsResolver resolver = factory.create(new Channel()); Assertions.assertThrows(UnresolvedMavenArtifactException.class, () -> { resolver.getMetadataLatestVersion("org.foo", "bar"); From 34299d72cf3d218d5d1be60575bda1f10d86c982 Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Tue, 15 Oct 2024 14:26:49 +0100 Subject: [PATCH 10/10] Add missing key to the exception information --- .../main/java/org/wildfly/channel/Channel.java | 4 ++-- .../wildfly/channel/spi/SignatureResult.java | 4 ++-- .../channel/spi/SignatureValidator.java | 15 +++++++++++++-- .../SignedVersionResolverWrapperTest.java | 4 ++-- gpg-validator/pom.xml | 2 +- .../channel/gpg/GpgSignatureValidator.java | 18 +++++++++++------- 6 files changed, 31 insertions(+), 16 deletions(-) diff --git a/core/src/main/java/org/wildfly/channel/Channel.java b/core/src/main/java/org/wildfly/channel/Channel.java index d31f22b6..0f022638 100644 --- a/core/src/main/java/org/wildfly/channel/Channel.java +++ b/core/src/main/java/org/wildfly/channel/Channel.java @@ -231,14 +231,14 @@ public Builder() { public Builder(Channel from) { this.name = from.getName(); - this.repositories = new ArrayList<>(from.getRepositories()); + this.repositories = from.getRepositories() == null ? null : new ArrayList<>(from.getRepositories()); this.manifestCoordinate = from.getManifestCoordinate(); this.blocklistCoordinate = from.getBlocklistCoordinate(); this.strategy = from.getNoStreamStrategy(); this.description = from.getDescription(); this.vendor = from.getVendor(); this.gpgCheck = from._isGpgCheck(); - this.gpgUrls = new ArrayList<>(from.getGpgUrls()); + this.gpgUrls = from.getGpgUrls() == null ? null : new ArrayList<>(from.getGpgUrls()); } public Channel build() { diff --git a/core/src/main/java/org/wildfly/channel/spi/SignatureResult.java b/core/src/main/java/org/wildfly/channel/spi/SignatureResult.java index f02eb8e5..0bbf694e 100644 --- a/core/src/main/java/org/wildfly/channel/spi/SignatureResult.java +++ b/core/src/main/java/org/wildfly/channel/spi/SignatureResult.java @@ -50,8 +50,8 @@ public static SignatureResult noSignature(ArtifactIdentifier resource) { return new SignatureResult(Result.NO_SIGNATURE, resource, null, null); } - public static SignatureResult invalid(ArtifactIdentifier resource) { - return new SignatureResult(Result.INVALID, resource, null, null); + public static SignatureResult invalid(ArtifactIdentifier resource, String keyID) { + return new SignatureResult(Result.INVALID, resource, keyID, null); } public enum Result {OK, NO_MATCHING_CERT, REVOKED, EXPIRED, NO_SIGNATURE, INVALID;} diff --git a/core/src/main/java/org/wildfly/channel/spi/SignatureValidator.java b/core/src/main/java/org/wildfly/channel/spi/SignatureValidator.java index 4a1da13f..daa98dae 100644 --- a/core/src/main/java/org/wildfly/channel/spi/SignatureValidator.java +++ b/core/src/main/java/org/wildfly/channel/spi/SignatureValidator.java @@ -48,19 +48,30 @@ SignatureResult validateSignature(ArtifactIdentifier artifactId, InputStream art */ class SignatureException extends RuntimeException { private final SignatureResult signatureResult; + private String missingSignature; public SignatureException(String message, Throwable cause, SignatureResult signatureResult) { - super(message, cause); + super(buildErrorMessage(message, signatureResult), cause); this.signatureResult = signatureResult; + this.missingSignature = signatureResult.getKeyId(); } public SignatureException(String message, SignatureResult signatureResult) { - super(message); + super(buildErrorMessage(message, signatureResult)); this.signatureResult = signatureResult; + this.missingSignature = signatureResult.getKeyId(); + } + + private static String buildErrorMessage(String message, SignatureResult signatureResult) { + return String.format("%s: %s%s", message, signatureResult.getResult(), signatureResult.getMessage() == null ? "" : signatureResult.getResult()); } public SignatureResult getSignatureResult() { return signatureResult; } + + public String getMissingSignature() { + return missingSignature; + } } } diff --git a/core/src/test/java/org/wildfly/channel/SignedVersionResolverWrapperTest.java b/core/src/test/java/org/wildfly/channel/SignedVersionResolverWrapperTest.java index d781b6c8..e9a9ee91 100644 --- a/core/src/test/java/org/wildfly/channel/SignedVersionResolverWrapperTest.java +++ b/core/src/test/java/org/wildfly/channel/SignedVersionResolverWrapperTest.java @@ -92,7 +92,7 @@ public void invalidSignatureCausesError() throws Exception { when(resolver.resolveArtifact("test.channels", "base-manifest", ChannelManifest.EXTENSION + SIGNATURE_FILE_SUFFIX, ChannelManifest.CLASSIFIER, "1.0.0")) .thenReturn(tempDir.resolve("test-manifest.yaml.asc").toFile()); - when(signatureValidator.validateSignature(any(), any(), any(), any())).thenReturn(SignatureResult.invalid(mock(ArtifactIdentifier.class))); + when(signatureValidator.validateSignature(any(), any(), any(), any())).thenReturn(SignatureResult.invalid(mock(ArtifactIdentifier.class), "abcd")); assertThrows(SignatureValidator.SignatureException.class, () -> signedResolver.resolveChannelMetadata(List.of(new ChannelManifestCoordinate("test.channels", "base-manifest", "1.0.0")))); } @@ -154,7 +154,7 @@ public void failedSignatureValidationThrowsException() throws Exception { ARTIFACT.classifier, ARTIFACT.version)) .thenReturn(signatureFile); when(signatureValidator.validateSignature(eq(ARTIFACT), - any(), any(), any())).thenReturn(SignatureResult.invalid(ARTIFACT)); + any(), any(), any())).thenReturn(SignatureResult.invalid(ARTIFACT, "abcd")); assertThrows(SignatureValidator.SignatureException.class, () -> signedResolver.resolveArtifact(ARTIFACT.groupId, ARTIFACT.artifactId, ARTIFACT.extension, ARTIFACT.classifier, ARTIFACT.version)); diff --git a/gpg-validator/pom.xml b/gpg-validator/pom.xml index 39ab2bc3..0dac107d 100644 --- a/gpg-validator/pom.xml +++ b/gpg-validator/pom.xml @@ -3,7 +3,7 @@ channel-parent org.wildfly.channel - 1.2.1.Final-SNAPSHOT + 1.2.2.Final-SNAPSHOT 4.0.0 diff --git a/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidator.java b/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidator.java index 889c7dfa..84d38174 100644 --- a/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidator.java +++ b/gpg-validator/src/main/java/org/wildfly/channel/gpg/GpgSignatureValidator.java @@ -105,7 +105,7 @@ public SignatureResult validateSignature(ArtifactIdentifier artifactId, InputStr return SignatureResult.noSignature(artifactId); } - final String keyID = Long.toHexString(pgpSignature.getKeyID()).toUpperCase(Locale.ROOT); + final String keyID = getKeyID(pgpSignature); if (LOG.isTraceEnabled()) { LOG.tracef("The signature was created using public key %s.", keyID); } @@ -134,7 +134,7 @@ public SignatureResult validateSignature(ArtifactIdentifier artifactId, InputStr } } catch (PGPException | IOException e) { throw new SignatureException("Unable to parse the certificate downloaded from keyserver", e, - SignatureResult.noSignature(artifactId)); + SignatureResult.noMatchingCertificate(artifactId, keyID)); } if (key == null) { @@ -146,7 +146,7 @@ public SignatureResult validateSignature(ArtifactIdentifier artifactId, InputStr pgpPublicKeys = downloadPublicKey(gpgUrl); } catch (IOException e) { throw new SignatureException("Unable to parse the certificate downloaded from " + gpgUrl, e, - SignatureResult.noSignature(artifactId)); + SignatureResult.noMatchingCertificate(artifactId, keyID)); } if (pgpPublicKeys.stream().anyMatch(k -> k.getKeyID() == pgpSignature.getKeyID())) { key = pgpPublicKeys.stream().filter(k -> k.getKeyID() == pgpSignature.getKeyID()).findFirst().get(); @@ -193,7 +193,7 @@ public SignatureResult validateSignature(ArtifactIdentifier artifactId, InputStr pgpSignature.init(new BcPGPContentVerifierBuilderProvider(), publicKey); } catch (PGPException e) { throw new SignatureException("Unable to verify the signature using key " + keyID, e, - SignatureResult.invalid(artifactId)); + SignatureResult.invalid(artifactId, keyID)); } final SignatureResult result = verifyFile(artifactId, artifactStream, pgpSignature); @@ -241,7 +241,7 @@ private SignatureResult checkRevoked(ArtifactIdentifier artifactId, String keyID final Iterator subKeys = publicKey.getSignaturesOfType(PGPSignature.SUBKEY_BINDING); while (subKeys.hasNext()) { final PGPSignature subKeySignature = subKeys.next(); - final PGPPublicKey subKey = keystore.get(Long.toHexString(subKeySignature.getKeyID()).toUpperCase(Locale.ROOT)); + final PGPPublicKey subKey = keystore.get(getKeyID(subKeySignature)); if (subKey.hasRevocation()) { if (LOG.isTraceEnabled()) { LOG.tracef("Sub-key %s has been revoked.", Long.toHexString(subKey.getKeyID()).toUpperCase(Locale.ROOT)); @@ -285,16 +285,20 @@ private static SignatureResult verifyFile(ArtifactIdentifier artifactSource, Inp // Verify the signature try { if (!pgpSignature.verify()) { - return SignatureResult.invalid(artifactSource); + return SignatureResult.invalid(artifactSource, getKeyID(pgpSignature)); } else { return SignatureResult.ok(); } } catch (PGPException e) { throw new SignatureException("Unable to verify the file signature", e, - SignatureResult.invalid(artifactSource)); + SignatureResult.invalid(artifactSource, getKeyID(pgpSignature))); } } + private static String getKeyID(PGPSignature pgpSignature) { + return Long.toHexString(pgpSignature.getKeyID()).toUpperCase(Locale.ROOT); + } + private static PGPSignature readSignatureFile(InputStream signatureStream) throws IOException { PGPSignature pgpSignature = null; try (InputStream decoderStream = PGPUtil.getDecoderStream(signatureStream)) {