From d7742bad18e54d0c54c597d338c5210846e9cc47 Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Thu, 12 Sep 2024 12:38:47 +0100 Subject: [PATCH 01/11] Upgrade wildfly-channels --- .../prospero/it/commonapi/SimpleProvisionTest.java | 2 +- pom.xml | 4 ++-- .../org/wildfly/prospero/actions/ProvisioningAction.java | 6 +++--- .../prospero/api/TemporaryRepositoriesHandler.java | 5 +++-- .../prospero/galleon/CachedVersionResolverFactory.java | 7 +++---- .../prospero/galleon/ChannelManifestSubstitutor.java | 2 +- .../org/wildfly/prospero/galleon/GalleonEnvironment.java | 8 +++++--- .../org/wildfly/prospero/promotion/ArtifactPromoter.java | 7 +++++-- .../prospero/galleon/ChannelManifestSubstitutorTest.java | 7 +++++-- .../wildfly/prospero/galleon/GalleonEnvironmentTest.java | 4 ++-- .../wildfly/prospero/promotion/ArtifactPromoterTest.java | 6 ++++-- 11 files changed, 34 insertions(+), 24 deletions(-) diff --git a/integration-tests/src/test/java/org/wildfly/prospero/it/commonapi/SimpleProvisionTest.java b/integration-tests/src/test/java/org/wildfly/prospero/it/commonapi/SimpleProvisionTest.java index d5716d6db..881ccc3ce 100644 --- a/integration-tests/src/test/java/org/wildfly/prospero/it/commonapi/SimpleProvisionTest.java +++ b/integration-tests/src/test/java/org/wildfly/prospero/it/commonapi/SimpleProvisionTest.java @@ -102,7 +102,7 @@ public void installWildflyCore_ChannelsWithEmptyNamesAreNamed() throws Exception // make sure the channel names are empty List emptyNameChannels = MetadataTestUtils.readChannels(channelsFile).stream() .map(c -> new Channel(c.getSchemaVersion(), null, null, null, c.getRepositories(), - c.getManifestCoordinate(), null, null)).collect(Collectors.toList()); + c.getManifestCoordinate(), null, null, false, null)).collect(Collectors.toList()); MetadataTestUtils.writeChannels(channelsFile, emptyNameChannels); final ProvisioningDefinition provisioningDefinition = defaultWfCoreDefinition() diff --git a/pom.xml b/pom.xml index 30c86a21b..f56e323a9 100644 --- a/pom.xml +++ b/pom.xml @@ -68,13 +68,13 @@ 7.1.2.Final 1.0.3.Final 2.4.1.Final - 1.2.1.Final + 1.2.2.Final-SNAPSHOT 5.13.0 2.0.7 2.2 4.13.2 3.6.0 - 1.1.0.Final + 1.1.1.Final-SNAPSHOT 3.10.1 33.0.1.Final 4.7.6 diff --git a/prospero-common/src/main/java/org/wildfly/prospero/actions/ProvisioningAction.java b/prospero-common/src/main/java/org/wildfly/prospero/actions/ProvisioningAction.java index 212395fcc..09e6b7f65 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/actions/ProvisioningAction.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/actions/ProvisioningAction.java @@ -247,9 +247,9 @@ private List enforceChannelNames(List newChannels) { final AtomicInteger channelCounter = new AtomicInteger(0); return newChannels.stream().map(c->{ if (StringUtils.isEmpty(c.getName())) { - return new Channel(c.getSchemaVersion(), CHANNEL_NAME_PREFIX + channelCounter.getAndIncrement(), c.getDescription(), - c.getVendor(), c.getRepositories(), - c.getManifestCoordinate(), c.getBlocklistCoordinate(), c.getNoStreamStrategy()); + return new Channel.Builder(c) + .setName(CHANNEL_NAME_PREFIX + channelCounter.getAndIncrement()) + .build(); } else { return c; } diff --git a/prospero-common/src/main/java/org/wildfly/prospero/api/TemporaryRepositoriesHandler.java b/prospero-common/src/main/java/org/wildfly/prospero/api/TemporaryRepositoriesHandler.java index 97fafa6de..41bd540f9 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/api/TemporaryRepositoriesHandler.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/api/TemporaryRepositoriesHandler.java @@ -37,8 +37,9 @@ public static List overrideRepositories(List originalChannels, ArrayList mergedChannels = new ArrayList<>(originalChannels.size()); for (Channel oc : originalChannels) { - final Channel c = new Channel(oc.getSchemaVersion(), oc.getName(), oc.getDescription(), oc.getVendor(), - repositories, oc.getManifestCoordinate(), oc.getBlocklistCoordinate(), oc.getNoStreamStrategy()); + final Channel c = new Channel.Builder(oc) + .setRepositories(repositories) + .build(); mergedChannels.add(c); } diff --git a/prospero-common/src/main/java/org/wildfly/prospero/galleon/CachedVersionResolverFactory.java b/prospero-common/src/main/java/org/wildfly/prospero/galleon/CachedVersionResolverFactory.java index 93fb807b6..434bbae4a 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/galleon/CachedVersionResolverFactory.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/galleon/CachedVersionResolverFactory.java @@ -20,7 +20,7 @@ import org.eclipse.aether.DefaultRepositorySystemSession; import org.eclipse.aether.RepositorySystem; import org.wildfly.channel.ArtifactCoordinate; -import org.wildfly.channel.Repository; +import org.wildfly.channel.Channel; import org.wildfly.channel.maven.VersionResolverFactory; import org.wildfly.channel.spi.MavenVersionsResolver; import org.wildfly.prospero.metadata.ManifestVersionRecord; @@ -28,7 +28,6 @@ import java.io.IOException; import java.nio.file.Path; -import java.util.Collection; import java.util.List; import java.util.Optional; @@ -49,8 +48,8 @@ public CachedVersionResolverFactory(VersionResolverFactory factory, Path install } @Override - public MavenVersionsResolver create(Collection repositories) { - return new CachedVersionResolver(factory.create(repositories), artifactCache, system, session, + public MavenVersionsResolver create(Channel channel) { + return new CachedVersionResolver(factory.create(channel), artifactCache, system, session, (a)->getCurrentManifestVersion(a, installDir.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve(ProsperoMetadataUtils.CURRENT_VERSION_FILE))); } diff --git a/prospero-common/src/main/java/org/wildfly/prospero/galleon/ChannelManifestSubstitutor.java b/prospero-common/src/main/java/org/wildfly/prospero/galleon/ChannelManifestSubstitutor.java index 007597a56..f9a4dfaee 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/galleon/ChannelManifestSubstitutor.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/galleon/ChannelManifestSubstitutor.java @@ -66,7 +66,7 @@ public Channel substitute(Channel channel) throws MetadataException { channel.getBlocklistCoordinate(), channel.getNoStreamStrategy()); } else { return new Channel(channel.getSchemaVersion(), channel.getName(), channel.getDescription(), channel.getVendor(), channel.getRepositories(), substitutedChannelManifestCoordinate, - channel.getBlocklistCoordinate(), channel.getNoStreamStrategy()); + channel.getBlocklistCoordinate(), channel.getNoStreamStrategy(), channel.isGpgCheck(), channel.getGpgUrls()); } } } diff --git a/prospero-common/src/main/java/org/wildfly/prospero/galleon/GalleonEnvironment.java b/prospero-common/src/main/java/org/wildfly/prospero/galleon/GalleonEnvironment.java index 7db696635..89bd6d22e 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/galleon/GalleonEnvironment.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/galleon/GalleonEnvironment.java @@ -104,10 +104,10 @@ private GalleonEnvironment(Builder builder) throws ProvisioningException, Metada final Path sourceServerPath = builder.sourceServerPath == null? builder.installDir:builder.sourceServerPath; MavenVersionsResolver.Factory factory; try { - factory = new CachedVersionResolverFactory(new VersionResolverFactory(system, session, MavenProxyHandler::addProxySettings), sourceServerPath, system, session); + factory = new CachedVersionResolverFactory(new VersionResolverFactory(system, session, null, MavenProxyHandler::addProxySettings), sourceServerPath, system, session); } catch (IOException e) { ProsperoLogger.ROOT_LOGGER.debug("Unable to read artifact cache, falling back to Maven resolver.", e); - factory = new VersionResolverFactory(system, session, MavenProxyHandler::addProxySettings); + factory = new VersionResolverFactory(system, session, null, MavenProxyHandler::addProxySettings); } channelSession = initChannelSession(session, factory); @@ -203,7 +203,9 @@ private List replaceManifestWithRestoreManifests(Builder builder, Optio c.getRepositories(), manifestCoord, c.getBlocklistCoordinate(), - c.getNoStreamStrategy())) + c.getNoStreamStrategy(), + c.isGpgCheck(), + c.getGpgUrls())) .collect(Collectors.toList()); return channels; } diff --git a/prospero-common/src/main/java/org/wildfly/prospero/promotion/ArtifactPromoter.java b/prospero-common/src/main/java/org/wildfly/prospero/promotion/ArtifactPromoter.java index 50032fd66..2118696af 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/promotion/ArtifactPromoter.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/promotion/ArtifactPromoter.java @@ -33,9 +33,9 @@ import org.eclipse.aether.version.Version; import org.jboss.logging.Logger; import org.wildfly.channel.ArtifactCoordinate; +import org.wildfly.channel.Channel; import org.wildfly.channel.ChannelManifest; import org.wildfly.channel.ChannelManifestMapper; -import org.wildfly.channel.Repository; import org.wildfly.channel.Stream; import org.wildfly.channel.maven.ChannelCoordinate; import org.wildfly.channel.maven.VersionResolverFactory; @@ -147,7 +147,10 @@ private ChannelManifest resolveDeployedChannel(ChannelCoordinate coordinate, Opt log.debugf("Found existing customization channel with version %s", version.get()); try(VersionResolverFactory versionResolverFactory = new VersionResolverFactory(system, session)) { - final MavenVersionsResolver resolver = versionResolverFactory.create(Arrays.asList(new Repository(targetRepository.getId(), targetRepository.getUrl()))); + final MavenVersionsResolver resolver = versionResolverFactory.create( + new Channel.Builder() + .addRepository(targetRepository.getId(), targetRepository.getUrl()) + .build()); final File file = resolver.resolveArtifact(coordinate.getGroupId(), coordinate.getArtifactId(), ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER, version.get()); diff --git a/prospero-common/src/test/java/org/wildfly/prospero/galleon/ChannelManifestSubstitutorTest.java b/prospero-common/src/test/java/org/wildfly/prospero/galleon/ChannelManifestSubstitutorTest.java index 631d8a0fc..2bc09f888 100644 --- a/prospero-common/src/test/java/org/wildfly/prospero/galleon/ChannelManifestSubstitutorTest.java +++ b/prospero-common/src/test/java/org/wildfly/prospero/galleon/ChannelManifestSubstitutorTest.java @@ -36,8 +36,11 @@ public void testChannelManifestSubstituted() throws MalformedURLException, Metad String url = "file:${propName}/examples/wildfly-27.0.0.Alpha2-manifest.yaml"; final ChannelManifestSubstitutor substitutor = new ChannelManifestSubstitutor(Map.of("propName", "propValue")); String expected = "file:propValue/examples/wildfly-27.0.0.Alpha2-manifest.yaml"; - Channel channel = new Channel("channel1", "", null, null, List.of(new Repository("test", "http://test.org")), - ChannelManifestCoordinate.create(url, null), null, null); + Channel channel = new Channel.Builder() + .setName("channel1") + .addRepository("test", "http://test.org") + .setManifestCoordinate(ChannelManifestCoordinate.create(url, null)) + .build(); Channel substitutedChannel = substitutor.substitute(channel); System.clearProperty("propName"); assertEquals(expected, substitutedChannel.getManifestCoordinate().getUrl().toString()); diff --git a/prospero-common/src/test/java/org/wildfly/prospero/galleon/GalleonEnvironmentTest.java b/prospero-common/src/test/java/org/wildfly/prospero/galleon/GalleonEnvironmentTest.java index 27ea0499d..5523fdfb5 100644 --- a/prospero-common/src/test/java/org/wildfly/prospero/galleon/GalleonEnvironmentTest.java +++ b/prospero-common/src/test/java/org/wildfly/prospero/galleon/GalleonEnvironmentTest.java @@ -110,7 +110,7 @@ public void populateMavenCacheWithRevertManifests_MavenManifestsWithVersion_Call final ManifestVersionRecord record = new ManifestVersionRecord(); record.addManifest(new ManifestVersionRecord.MavenManifest(manifestArtifact.getGroupId(), manifestArtifact.getArtifactId(), manifestArtifact.getVersion(), "desc")); - GalleonEnvironment.builder(temp.newFolder().toPath(), List.of(), msm, true) + GalleonEnvironment.builder(temp.newFolder().toPath(), List.of(new Channel()), msm, true) .setRestoreManifest(restoreManifest, record) .build(); @@ -142,7 +142,7 @@ public void populateMavenCacheWithRevertManifests_MavenManifestsWithVersion_Igno missingArtifact.getVersion(), "desc")); record.addManifest(new ManifestVersionRecord.MavenManifest(manifestArtifact.getGroupId(), manifestArtifact.getArtifactId(), manifestArtifact.getVersion(), "desc")); - GalleonEnvironment.builder(temp.newFolder().toPath(), List.of(), msm, true) + GalleonEnvironment.builder(temp.newFolder().toPath(), List.of(new Channel()), msm, true) .setRestoreManifest(restoreManifest, record) .build(); diff --git a/prospero-common/src/test/java/org/wildfly/prospero/promotion/ArtifactPromoterTest.java b/prospero-common/src/test/java/org/wildfly/prospero/promotion/ArtifactPromoterTest.java index 72acd1558..a6f840c3e 100644 --- a/prospero-common/src/test/java/org/wildfly/prospero/promotion/ArtifactPromoterTest.java +++ b/prospero-common/src/test/java/org/wildfly/prospero/promotion/ArtifactPromoterTest.java @@ -33,9 +33,9 @@ import org.junit.Test; import org.junit.rules.TemporaryFolder; import org.wildfly.channel.ArtifactTransferException; +import org.wildfly.channel.Channel; import org.wildfly.channel.ChannelManifest; import org.wildfly.channel.ChannelManifestMapper; -import org.wildfly.channel.Repository; import org.wildfly.channel.Stream; import org.wildfly.channel.UnresolvedMavenArtifactException; import org.wildfly.channel.maven.ChannelCoordinate; @@ -258,7 +258,9 @@ private void assertStreamMatches(String groupId, String artifactId, String versi } private ChannelManifest getManifest(ChannelCoordinate channelGa) throws IOException { - final MavenVersionsResolver resolver = new VersionResolverFactory(system, session).create(Arrays.asList(new Repository(targetRepository.getId(), targetRepository.getUrl()))); + final MavenVersionsResolver resolver = new VersionResolverFactory(system, session).create(new Channel.Builder() + .addRepository(targetRepository.getId(), targetRepository.getUrl()) + .build()); final Set allVersions = resolver.getAllVersions(channelGa.getGroupId(), channelGa.getArtifactId(), ChannelManifest.EXTENSION, ChannelManifest.CLASSIFIER); From 864616a5366e323175abee04b16f19a267c0e45f Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Thu, 12 Sep 2024 13:47:44 +0100 Subject: [PATCH 02/11] Adding bouncycastle and gpg-validator dependencies --- .../base/org/jboss/prospero/main/module.xml | 1 + dist/standalone-galleon-pack/pom.xml | 41 +++++++++++++++++++ .../org/jboss/prospero-dep/main/module.xml | 3 ++ dist/wildfly-galleon-pack/pom.xml | 11 +++++ .../org/jboss/prospero-dep/main/module.xml | 1 + pom.xml | 34 +++++++++++++++ 6 files changed, 91 insertions(+) diff --git a/dist/common/src/main/resources/modules/system/layers/base/org/jboss/prospero/main/module.xml b/dist/common/src/main/resources/modules/system/layers/base/org/jboss/prospero/main/module.xml index 5fc921d2a..14c894f8e 100644 --- a/dist/common/src/main/resources/modules/system/layers/base/org/jboss/prospero/main/module.xml +++ b/dist/common/src/main/resources/modules/system/layers/base/org/jboss/prospero/main/module.xml @@ -29,6 +29,7 @@ + diff --git a/dist/standalone-galleon-pack/pom.xml b/dist/standalone-galleon-pack/pom.xml index aa6f4b08b..fe7e2a324 100644 --- a/dist/standalone-galleon-pack/pom.xml +++ b/dist/standalone-galleon-pack/pom.xml @@ -100,6 +100,16 @@ + + org.wildfly.channel + gpg-validator + + + * + * + + + org.wildfly.channel maven-resolver @@ -111,6 +121,37 @@ + + org.bouncycastle + bcpg-jdk18on + + + * + * + + + + + org.bouncycastle + bcprov-jdk18on + + + * + * + + + + + org.bouncycastle + bcutil-jdk18on + + + * + * + + + + org.codehaus.plexus plexus-interpolation diff --git a/dist/standalone-galleon-pack/src/main/resources/modules/system/layers/base/org/jboss/prospero-dep/main/module.xml b/dist/standalone-galleon-pack/src/main/resources/modules/system/layers/base/org/jboss/prospero-dep/main/module.xml index 53ec7030c..70bb36272 100644 --- a/dist/standalone-galleon-pack/src/main/resources/modules/system/layers/base/org/jboss/prospero-dep/main/module.xml +++ b/dist/standalone-galleon-pack/src/main/resources/modules/system/layers/base/org/jboss/prospero-dep/main/module.xml @@ -29,6 +29,9 @@ + + + diff --git a/dist/wildfly-galleon-pack/pom.xml b/dist/wildfly-galleon-pack/pom.xml index 40a1200de..138bea333 100644 --- a/dist/wildfly-galleon-pack/pom.xml +++ b/dist/wildfly-galleon-pack/pom.xml @@ -86,6 +86,17 @@ + + org.wildfly.channel + gpg-validator + + + * + * + + + + org.wildfly.channel maven-resolver diff --git a/dist/wildfly-galleon-pack/src/main/resources/modules/system/layers/base/org/jboss/prospero-dep/main/module.xml b/dist/wildfly-galleon-pack/src/main/resources/modules/system/layers/base/org/jboss/prospero-dep/main/module.xml index 284a5a528..0cb83763d 100644 --- a/dist/wildfly-galleon-pack/src/main/resources/modules/system/layers/base/org/jboss/prospero-dep/main/module.xml +++ b/dist/wildfly-galleon-pack/src/main/resources/modules/system/layers/base/org/jboss/prospero-dep/main/module.xml @@ -28,6 +28,7 @@ + diff --git a/pom.xml b/pom.xml index f56e323a9..ab85ac76e 100644 --- a/pom.xml +++ b/pom.xml @@ -52,6 +52,7 @@ 3.8.0 1.9.21 3.6.3 + 1.76 2.1.1 1.27 3.5.0 @@ -65,6 +66,7 @@ 2.1.5.Final 3.8.16.Final 1.7.0.Final + 1.6.1 7.1.2.Final 1.0.3.Final 2.4.1.Final @@ -92,6 +94,26 @@ + + org.wildfly.channel + gpg-validator + ${version.org.wildfly.channel} + + + org.bouncycastle + bcpg-jdk18on + ${version.org.bouncycastle} + + + org.bouncycastle + bcprov-jdk18on + ${version.org.bouncycastle} + + + org.bouncycastle + bcutil-jdk18on + ${version.org.bouncycastle} + org.wildfly.channel channel-parent @@ -450,6 +472,18 @@ ${version.org.jboss.xnio} test + + org.pgpainless + pgpainless-core + ${version.org.pgpainless} + test + + + org.pgpainless + pgpainless-sop + ${version.org.pgpainless} + test + From 4250f3adeb0930df2307324d7d700b48b563269b Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Thu, 12 Sep 2024 14:10:12 +0100 Subject: [PATCH 03/11] Implementation of PGP store --- prospero-common/pom.xml | 15 + .../org/wildfly/prospero/ProsperoLogger.java | 21 + .../signatures/CachedPGPKeystore.java | 205 +++++++++ .../DuplicatedCertificateException.java | 34 ++ .../InvalidCertificateException.java | 26 ++ .../prospero/signatures/KeystoreManager.java | 135 ++++++ .../signatures/KeystoreWriteException.java | 26 ++ .../NoSuchCertificateException.java | 26 ++ .../wildfly/prospero/signatures/PGPKeyId.java | 64 +++ .../prospero/signatures/PGPLocalKeystore.java | 41 ++ .../prospero/signatures/PGPPublicKey.java | 60 +++ .../prospero/signatures/PGPPublicKeyInfo.java | 143 ++++++ .../signatures/PGPRevokeSignature.java | 60 +++ .../signatures/KeystoreManagerTest.java | 91 ++++ .../signatures/PGPLocalKeystoreTest.java | 413 ++++++++++++++++++ .../prospero/utils/TestSignatureUtils.java | 165 +++++++ 16 files changed, 1525 insertions(+) create mode 100644 prospero-common/src/main/java/org/wildfly/prospero/signatures/CachedPGPKeystore.java create mode 100644 prospero-common/src/main/java/org/wildfly/prospero/signatures/DuplicatedCertificateException.java create mode 100644 prospero-common/src/main/java/org/wildfly/prospero/signatures/InvalidCertificateException.java create mode 100644 prospero-common/src/main/java/org/wildfly/prospero/signatures/KeystoreManager.java create mode 100644 prospero-common/src/main/java/org/wildfly/prospero/signatures/KeystoreWriteException.java create mode 100644 prospero-common/src/main/java/org/wildfly/prospero/signatures/NoSuchCertificateException.java create mode 100644 prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPKeyId.java create mode 100644 prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPLocalKeystore.java create mode 100644 prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPPublicKey.java create mode 100644 prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPPublicKeyInfo.java create mode 100644 prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPRevokeSignature.java create mode 100644 prospero-common/src/test/java/org/wildfly/prospero/signatures/KeystoreManagerTest.java create mode 100644 prospero-common/src/test/java/org/wildfly/prospero/signatures/PGPLocalKeystoreTest.java create mode 100644 prospero-common/src/test/java/org/wildfly/prospero/utils/TestSignatureUtils.java diff --git a/prospero-common/pom.xml b/prospero-common/pom.xml index 2dc11d66f..83eaa95c3 100644 --- a/prospero-common/pom.xml +++ b/prospero-common/pom.xml @@ -13,6 +13,10 @@ jar + + org.wildfly.channel + gpg-validator + org.wildfly.installation-manager installation-manager-api @@ -127,6 +131,17 @@ assertj-core test + + org.pgpainless + pgpainless-core + test + + + org.pgpainless + pgpainless-sop + test + + diff --git a/prospero-common/src/main/java/org/wildfly/prospero/ProsperoLogger.java b/prospero-common/src/main/java/org/wildfly/prospero/ProsperoLogger.java index cab8ff124..537f7b71b 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/ProsperoLogger.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/ProsperoLogger.java @@ -25,14 +25,20 @@ import org.jboss.logging.annotations.LogMessage; import org.jboss.logging.annotations.Message; import org.jboss.logging.annotations.MessageLogger; +import org.jboss.logging.annotations.Param; +import org.jboss.logging.annotations.Pos; +import org.wildfly.prospero.signatures.KeystoreWriteException; +import org.wildfly.prospero.signatures.DuplicatedCertificateException; import org.wildfly.channel.InvalidChannelMetadataException; import org.wildfly.prospero.actions.FeaturesAddAction; import org.wildfly.prospero.api.exceptions.ArtifactPromoteException; import org.wildfly.prospero.api.exceptions.ChannelDefinitionException; +import org.wildfly.prospero.signatures.InvalidCertificateException; import org.wildfly.prospero.api.exceptions.InvalidRepositoryArchiveException; import org.wildfly.prospero.api.exceptions.InvalidUpdateCandidateException; import org.wildfly.prospero.api.exceptions.MetadataException; import org.wildfly.prospero.api.exceptions.NoChannelException; +import org.wildfly.prospero.signatures.NoSuchCertificateException; import org.wildfly.prospero.api.exceptions.ProvisioningRuntimeException; import java.io.IOException; @@ -392,4 +398,19 @@ public interface ProsperoLogger extends BasicLogger { @Message(id = 272, value = "Failed to apply the candidate changes due to: %s") String failedToApplyCandidate(String reason); + + @Message(id = 273, value = "The certificate at %s is invalid - %s") + InvalidCertificateException invalidCertificate(String certLocation, String message, @Cause Exception e); + + @Message(id = 274, value = "Unable to persist changes to local keystore %s - %s") + KeystoreWriteException unableToWriteKeystore(Path location, String message, @Cause Exception e); + + @Message(id = 275, value = "Unable to import a certificate - certificate %s already exists in the keystore.") + DuplicatedCertificateException certificateAlreadyExists(@Param @Pos(1) String keyID); + + @Message(id = 276, value = "The key %s was not found in the keyring.") + NoSuchCertificateException noSuchCertificate(String keyId); + + @Message(id = 277, value = "Unable to parse a keystore [%s]: %s") + MetadataException unableToReadKeyring(Path keyringPath, String localizedMessage, @Cause Exception e); } diff --git a/prospero-common/src/main/java/org/wildfly/prospero/signatures/CachedPGPKeystore.java b/prospero-common/src/main/java/org/wildfly/prospero/signatures/CachedPGPKeystore.java new file mode 100644 index 000000000..1f3e57ff2 --- /dev/null +++ b/prospero-common/src/main/java/org/wildfly/prospero/signatures/CachedPGPKeystore.java @@ -0,0 +1,205 @@ +/* + * 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.prospero.signatures; + +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.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; + +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator; +import org.eclipse.jgit.util.Hex; +import org.wildfly.prospero.ProsperoLogger; +import org.wildfly.prospero.api.exceptions.MetadataException; + +/** + * Represents a store containing trusted public keys used to verify components + */ +class CachedPGPKeystore implements PGPLocalKeystore { + + private final Path keyStoreFile; + private PGPPublicKeyRingCollection publicKeyRingCollection; + + /** + * Should only be created thrown {@code KeystoreManager} + * + * @param keyStoreFile + */ + CachedPGPKeystore(Path keyStoreFile) throws MetadataException { + this.keyStoreFile = keyStoreFile; + if (Files.exists(keyStoreFile)) { + try { + publicKeyRingCollection = new PGPPublicKeyRingCollection( + new FileInputStream(keyStoreFile.toFile()), + new JcaKeyFingerprintCalculator()); + } catch (IOException | PGPException e) { + throw ProsperoLogger.ROOT_LOGGER.unableToReadKeyring(keyStoreFile, e.getLocalizedMessage(), e); + } + } + } + + @Override + public void close() { + // no-op + } + + private synchronized PGPPublicKeyRingCollection getPublicKeyRingCollection() { + if (publicKeyRingCollection == null) { + publicKeyRingCollection = new PGPPublicKeyRingCollection(Collections.emptyList()); + } + return publicKeyRingCollection; + } + + @Override + public synchronized boolean removeCertificate(PGPKeyId keyId) throws KeystoreWriteException { + final Iterator keyRings = getPublicKeyRingCollection().getKeyRings(); + while (keyRings.hasNext()) { + final PGPPublicKeyRing keyRing = keyRings.next(); + final Iterator publicKeys = keyRing.getPublicKeys(); + while (publicKeys.hasNext()) { + final PGPPublicKey next = publicKeys.next(); + if (next.getKeyID() == keyId.getKeyID()) { + this.publicKeyRingCollection = PGPPublicKeyRingCollection.removePublicKeyRing(publicKeyRingCollection, keyRing); + + try(FileOutputStream outStream = new FileOutputStream(keyStoreFile.toFile())) { + getPublicKeyRingCollection().encode(outStream); + } catch (IOException e) { + throw ProsperoLogger.ROOT_LOGGER.unableToWriteKeystore(keyStoreFile, e.getLocalizedMessage(), e); + } + return true; + } + } + } + return false; + } + + @Override + public synchronized void revokeCertificate(PGPSignature pgpSignature) throws NoSuchCertificateException, KeystoreWriteException { + final long keyId = pgpSignature.getKeyID(); + + final PGPPublicKeyRingCollection publicKeyRingCollection = getPublicKeyRingCollection(); + final Iterator keyRings = publicKeyRingCollection.getKeyRings(); + PGPPublicKeyRing keyRing = null; + PGPPublicKey publicKey = null; + while (keyRings.hasNext()) { + keyRing = keyRings.next(); + publicKey = keyRing.getPublicKey(keyId); + if (publicKey != null) { + break; + } + } + + if (publicKey == null) { + throw ProsperoLogger.ROOT_LOGGER.noSuchCertificate(new PGPKeyId(keyId).getHexKeyID()); + } + + + final PGPPublicKey pgpPublicKey = PGPPublicKey.addCertification(publicKey, pgpSignature); + PGPPublicKeyRing newKeyRing = PGPPublicKeyRing.insertPublicKey(keyRing, pgpPublicKey); + + PGPPublicKeyRingCollection collection = PGPPublicKeyRingCollection.removePublicKeyRing(publicKeyRingCollection, keyRing); + collection = PGPPublicKeyRingCollection.addPublicKeyRing(collection, newKeyRing); + + this.publicKeyRingCollection = collection; + try(FileOutputStream outStream = new FileOutputStream(keyStoreFile.toFile())) { + getPublicKeyRingCollection().encode(outStream); + } catch (IOException e) { + throw ProsperoLogger.ROOT_LOGGER.unableToWriteKeystore(keyStoreFile, e.getLocalizedMessage(), e); + } + + } + + @Override + public synchronized void importCertificate(List pgpPublicKeys) throws DuplicatedCertificateException, KeystoreWriteException { + final PGPPublicKeyRing pgpPublicKeyRing = new PGPPublicKeyRing(pgpPublicKeys); + if (getKey(pgpPublicKeyRing.getPublicKey().getKeyID()) != null) { + throw ProsperoLogger.ROOT_LOGGER.certificateAlreadyExists(new PGPKeyId(pgpPublicKeyRing.getPublicKey().getKeyID()).getHexKeyID()); + } + publicKeyRingCollection = PGPPublicKeyRingCollection.addPublicKeyRing(getPublicKeyRingCollection(), pgpPublicKeyRing); + try(FileOutputStream outStream = new FileOutputStream(keyStoreFile.toFile())) { + getPublicKeyRingCollection().encode(outStream); + } catch (IOException e) { + throw ProsperoLogger.ROOT_LOGGER.unableToWriteKeystore(keyStoreFile, e.getLocalizedMessage(), e); + } + } + + @Override + public synchronized PGPPublicKey getCertificate(PGPKeyId keyId) { + return getKey(keyId.getKeyID()); + } + + private synchronized PGPPublicKey getKey(long keyID) { + final Iterator keyRings = getPublicKeyRingCollection().getKeyRings(); + while (keyRings.hasNext()) { + final PGPPublicKeyRing keyRing = keyRings.next(); + final PGPPublicKey publicKey = keyRing.getPublicKey(keyID); + if (publicKey != null) { + return publicKey; + } + } + return null; + } + + @Override + public synchronized Collection listCertificates() { + final Iterator keyRings = getPublicKeyRingCollection().getKeyRings(); + final ArrayList keyInfos = new ArrayList<>(); + while (keyRings.hasNext()) { + final PGPPublicKeyRing keyRing = keyRings.next(); + final Iterator publicKeys = keyRing.getPublicKeys(); + while (publicKeys.hasNext()) { + final PGPPublicKey key = publicKeys.next(); + final PGPKeyId keyID = new PGPKeyId(key.getKeyID()); + final String fingerprint = Hex.toHexString(key.getFingerprint()).toUpperCase(Locale.ROOT); + final Iterator userIDs = key.getUserIDs(); + final ArrayList tmpUserIds = new ArrayList<>(); + while (userIDs.hasNext()) { + tmpUserIds.add(userIDs.next()); + } + final List identities = Collections.unmodifiableList(tmpUserIds); + final LocalDateTime creationDate = key.getCreationTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); + final LocalDateTime expiryDate = key.getValidSeconds() == 0?null:creationDate.plusSeconds(key.getValidSeconds()); + final PGPPublicKeyInfo.Status status; + if (key.hasRevocation()) { + status = PGPPublicKeyInfo.Status.REVOKED; + } else if (expiryDate != null && expiryDate.isBefore(LocalDateTime.now())) { + status = PGPPublicKeyInfo.Status.EXPIRED; + } else { + status = PGPPublicKeyInfo.Status.TRUSTED; + } + keyInfos.add(new PGPPublicKeyInfo(keyID, status, fingerprint, identities, creationDate, expiryDate)); + } + } + + return keyInfos; + } +} diff --git a/prospero-common/src/main/java/org/wildfly/prospero/signatures/DuplicatedCertificateException.java b/prospero-common/src/main/java/org/wildfly/prospero/signatures/DuplicatedCertificateException.java new file mode 100644 index 000000000..2e9cd23da --- /dev/null +++ b/prospero-common/src/main/java/org/wildfly/prospero/signatures/DuplicatedCertificateException.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.prospero.signatures; + +import org.wildfly.prospero.api.exceptions.OperationException; + +public class DuplicatedCertificateException extends OperationException { + + private final String keyID; + + public DuplicatedCertificateException(String msg, String keyID) { + super(msg); + this.keyID = keyID; + } + + public String getKeyID() { + return keyID; + } +} diff --git a/prospero-common/src/main/java/org/wildfly/prospero/signatures/InvalidCertificateException.java b/prospero-common/src/main/java/org/wildfly/prospero/signatures/InvalidCertificateException.java new file mode 100644 index 000000000..4ed784184 --- /dev/null +++ b/prospero-common/src/main/java/org/wildfly/prospero/signatures/InvalidCertificateException.java @@ -0,0 +1,26 @@ +/* + * 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.prospero.signatures; + +import org.wildfly.prospero.api.exceptions.OperationException; + +public class InvalidCertificateException extends OperationException { + public InvalidCertificateException(String msg, Throwable e) { + super(msg, e); + } +} diff --git a/prospero-common/src/main/java/org/wildfly/prospero/signatures/KeystoreManager.java b/prospero-common/src/main/java/org/wildfly/prospero/signatures/KeystoreManager.java new file mode 100644 index 000000000..536788627 --- /dev/null +++ b/prospero-common/src/main/java/org/wildfly/prospero/signatures/KeystoreManager.java @@ -0,0 +1,135 @@ +/* + * 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.prospero.signatures; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPSignature; +import org.wildfly.prospero.api.exceptions.MetadataException; + +/** + * Creates and destroys keystores used to access trusted certificates. Any access to the keystore should be done through + * this manager. + */ +public final class KeystoreManager { + + private static final Map keyringCache = new HashMap<>(); + private static final Map> keyringsInUse = new HashMap<>(); + + /** + * Retrieves a keystore associated with this {@code keyStoreFile}. If one doesn't exist yet, it is created. + * + * @param keyStoreFile + * @return + * @throws MetadataException - if unable to read the keystore file + */ + public static synchronized PGPLocalKeystore keystoreFor(Path keyStoreFile) throws MetadataException { + if (!keyringCache.containsKey(keyStoreFile)) { + keyringCache.put(keyStoreFile, new CachedPGPKeystore(keyStoreFile)); + } + final CachedPGPKeystore keystore = keyringCache.get(keyStoreFile); + + if (!keyringsInUse.containsKey(keyStoreFile)) { + keyringsInUse.put(keyStoreFile, new ArrayList<>()); + } + final KeystoreWrapper keystoreWrapper = new KeystoreWrapper(keyStoreFile, keystore); + keyringsInUse.get(keyStoreFile).add(keystoreWrapper); + + return keystoreWrapper; + } + + /** + * Removes the keystore from the cache if it is no longer used. + * + * @param keystore + */ + static synchronized void keystoreClosed(KeystoreWrapper keystore) { + final List usedKeystores = keyringsInUse.get(keystore.getKeyStoreFile()); + if (usedKeystores != null) { + usedKeystores.remove(keystore); + + if (usedKeystores.isEmpty()) { + keyringsInUse.remove(keystore.getKeyStoreFile()); + keyringCache.remove(keystore.getKeyStoreFile()); + } + } + } + + /** + * Works together with KeystoreManager to make sure the keystores are closed correctly + */ + static class KeystoreWrapper implements PGPLocalKeystore { + + private final PGPLocalKeystore wrapped; + private final Path keyStoreFile; + + private KeystoreWrapper(Path keyStoreFile, PGPLocalKeystore wrapped) { + this.wrapped = wrapped; + this.keyStoreFile = keyStoreFile; + } + + PGPLocalKeystore getWrapped() { + return wrapped; + } + + /* + * remove the instance from manager + */ + @Override + public synchronized void close() { + keystoreClosed(this); + wrapped.close(); + } + + @Override + public boolean removeCertificate(PGPKeyId keyId) throws KeystoreWriteException { + return wrapped.removeCertificate(keyId); + } + + @Override + public void revokeCertificate(PGPSignature pgpSignature) throws KeystoreWriteException, NoSuchCertificateException { + wrapped.revokeCertificate(pgpSignature); + } + + @Override + public void importCertificate(List pgpPublicKeys) throws DuplicatedCertificateException, KeystoreWriteException { + wrapped.importCertificate(pgpPublicKeys); + } + + @Override + public PGPPublicKey getCertificate(PGPKeyId keyIdHex) { + return wrapped.getCertificate(keyIdHex); + } + + @Override + public Collection listCertificates() { + return wrapped.listCertificates(); + } + + // internal methods used to create the keystores + private Path getKeyStoreFile() { + return keyStoreFile; + } + } +} diff --git a/prospero-common/src/main/java/org/wildfly/prospero/signatures/KeystoreWriteException.java b/prospero-common/src/main/java/org/wildfly/prospero/signatures/KeystoreWriteException.java new file mode 100644 index 000000000..d325415de --- /dev/null +++ b/prospero-common/src/main/java/org/wildfly/prospero/signatures/KeystoreWriteException.java @@ -0,0 +1,26 @@ +/* + * 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.prospero.signatures; + +import org.wildfly.prospero.api.exceptions.OperationException; + +public class KeystoreWriteException extends OperationException { + public KeystoreWriteException(String msg, Throwable e) { + super(msg, e); + } +} diff --git a/prospero-common/src/main/java/org/wildfly/prospero/signatures/NoSuchCertificateException.java b/prospero-common/src/main/java/org/wildfly/prospero/signatures/NoSuchCertificateException.java new file mode 100644 index 000000000..1b1bae4e0 --- /dev/null +++ b/prospero-common/src/main/java/org/wildfly/prospero/signatures/NoSuchCertificateException.java @@ -0,0 +1,26 @@ +/* + * 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.prospero.signatures; + +import org.wildfly.prospero.api.exceptions.OperationException; + +public class NoSuchCertificateException extends OperationException { + public NoSuchCertificateException(String msg) { + super(msg); + } +} diff --git a/prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPKeyId.java b/prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPKeyId.java new file mode 100644 index 000000000..f5db17337 --- /dev/null +++ b/prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPKeyId.java @@ -0,0 +1,64 @@ +/* + * 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.prospero.signatures; + +import java.math.BigInteger; +import java.util.Locale; +import java.util.Objects; + +public class PGPKeyId { + + private final String keyID; + + public PGPKeyId(String keyID) { + this.keyID = keyID.toUpperCase(Locale.ROOT); + } + + public PGPKeyId(Long keyID) { + this.keyID = Long.toHexString(keyID).toUpperCase(Locale.ROOT); + } + + public String getHexKeyID() { + return keyID; + } + + public long getKeyID() { + // note have to use BigInteger, Long.parse produces negative long values + return new BigInteger(keyID, 16).longValue(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PGPKeyId pgpKeyId = (PGPKeyId) o; + return Objects.equals(keyID, pgpKeyId.keyID); + } + + @Override + public int hashCode() { + return Objects.hash(keyID); + } + + @Override + public String toString() { + return "PGPKeyId{" + + "keyID='" + keyID + '\'' + + '}'; + } +} diff --git a/prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPLocalKeystore.java b/prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPLocalKeystore.java new file mode 100644 index 000000000..c5a3b7bdc --- /dev/null +++ b/prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPLocalKeystore.java @@ -0,0 +1,41 @@ +/* + * 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.prospero.signatures; + +import java.util.Collection; +import java.util.List; + +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPSignature; + +/** + * + */ +public interface PGPLocalKeystore extends AutoCloseable { + void close(); + + boolean removeCertificate(PGPKeyId keyId) throws KeystoreWriteException; + + void revokeCertificate(PGPSignature pgpSignature) throws NoSuchCertificateException, KeystoreWriteException; + + void importCertificate(List pgpPublicKeys) throws DuplicatedCertificateException, KeystoreWriteException; + + PGPPublicKey getCertificate(PGPKeyId keyId); + + Collection listCertificates(); +} diff --git a/prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPPublicKey.java b/prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPPublicKey.java new file mode 100644 index 000000000..cd3df6efd --- /dev/null +++ b/prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPPublicKey.java @@ -0,0 +1,60 @@ +/* + * 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.prospero.signatures; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +import org.bouncycastle.bcpg.ArmoredInputStream; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator; +import org.wildfly.prospero.ProsperoLogger; + +public class PGPPublicKey { + private final String location; + private final PGPPublicKeyRing publicKeyRing; + + public PGPPublicKey(String location, InputStream inputStream) throws InvalidCertificateException { + this.location = location; + + try { + this.publicKeyRing = new PGPPublicKeyRing(new ArmoredInputStream(inputStream), new JcaKeyFingerprintCalculator()); + } catch (IOException e) { + throw ProsperoLogger.ROOT_LOGGER.invalidCertificate(location, e.getLocalizedMessage(), e); + } + } + + public PGPPublicKey(File certFile) throws InvalidCertificateException { + this.location = certFile.toPath().toAbsolutePath().toString(); + try (FileInputStream inputStream = new FileInputStream(certFile)) { + this.publicKeyRing = new PGPPublicKeyRing(new ArmoredInputStream(inputStream), new JcaKeyFingerprintCalculator()); + } catch (IOException e) { + throw ProsperoLogger.ROOT_LOGGER.invalidCertificate(location, e.getLocalizedMessage(), e); + } + } + + public String getLocation() { + return location; + } + + public PGPPublicKeyRing getPublicKeyRing() { + return publicKeyRing; + } +} diff --git a/prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPPublicKeyInfo.java b/prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPPublicKeyInfo.java new file mode 100644 index 000000000..ab405fd69 --- /dev/null +++ b/prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPPublicKeyInfo.java @@ -0,0 +1,143 @@ +/* + * 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.prospero.signatures; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collection; +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.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator; +import org.eclipse.jgit.util.Hex; +import org.wildfly.prospero.ProsperoLogger; + +/** + * Represents information contained in a public key + */ +public class PGPPublicKeyInfo { + + /** + * parses a certificate from a file. The certificate is expected to be armour-encoded. + * + * @param file + * @return + * @throws InvalidCertificateException - if the certificate cannot be parsed + */ + public static PGPPublicKeyInfo parse(File file) throws InvalidCertificateException { + final PGPPublicKeyRing pgpPublicKeys; + try { + pgpPublicKeys = new PGPPublicKeyRing(new ArmoredInputStream(new FileInputStream(file)), new JcaKeyFingerprintCalculator()); + } catch (IOException e) { + throw ProsperoLogger.ROOT_LOGGER.invalidCertificate(file.getAbsolutePath(), e.getLocalizedMessage(), e); + } + final PGPPublicKey key = pgpPublicKeys.getPublicKey(); + final Iterator userIDs = key.getUserIDs(); + final ArrayList tmpUserIds = new ArrayList<>(); + while (userIDs.hasNext()) { + tmpUserIds.add(userIDs.next()); + } + + PGPKeyId keyID = new PGPKeyId(key.getKeyID()); + String fingerprint = Hex.toHexString(key.getFingerprint()).toUpperCase(Locale.ROOT); + List identity = Collections.unmodifiableList(tmpUserIds); + Status status = key.hasRevocation() ? PGPPublicKeyInfo.Status.REVOKED : PGPPublicKeyInfo.Status.TRUSTED; + LocalDateTime issueDate = key.getCreationTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); + LocalDateTime expiryDate = key.getValidSeconds() == 0?null:issueDate.plusSeconds(key.getValidSeconds()); + + return new PGPPublicKeyInfo(keyID, status, fingerprint, identity, issueDate, expiryDate); + } + + private final PGPKeyId keyID; + + public enum Status {TRUSTED, EXPIRED, REVOKED} + + private final Status status; + private final String fingerprint; + private final List identity; + private final LocalDateTime issueDate; + private final LocalDateTime expiryDate; + + public PGPPublicKeyInfo(PGPKeyId keyID, Status status, String fingerprint, List identity, LocalDateTime issueDate, LocalDateTime expiryDate) { + this.keyID = keyID; + this.status = status; + this.fingerprint = fingerprint; + this.identity = identity; + this.issueDate = issueDate; + this.expiryDate = expiryDate; + } + + public PGPKeyId getKeyID() { + return keyID; + } + + public Status getStatus() { + return status; + } + + public String getFingerprint() { + return fingerprint; + } + + public Collection getIdentity() { + return identity; + } + + public LocalDateTime getIssueDate() { + return issueDate; + } + + public LocalDateTime getExpiryDate() { + return expiryDate; + } + + @Override + public String toString() { + return "KeyInfo{" + + "keyID='" + keyID + '\'' + + ", status=" + status + + ", fingerprint='" + fingerprint + '\'' + + ", identity=" + identity + + ", issueDate=" + issueDate + + ", expiryDate=" + expiryDate + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PGPPublicKeyInfo keyInfo = (PGPPublicKeyInfo) o; + return Objects.equals(keyID, keyInfo.keyID) && status == keyInfo.status && Objects.equals(fingerprint, keyInfo.fingerprint) && Objects.equals(identity, keyInfo.identity) && Objects.equals(issueDate, keyInfo.issueDate) && Objects.equals(expiryDate, keyInfo.expiryDate); + } + + @Override + public int hashCode() { + return Objects.hash(keyID, status, fingerprint, identity, issueDate, expiryDate); + } +} diff --git a/prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPRevokeSignature.java b/prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPRevokeSignature.java new file mode 100644 index 000000000..5b282208a --- /dev/null +++ b/prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPRevokeSignature.java @@ -0,0 +1,60 @@ +/* + * 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.prospero.signatures; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +import org.bouncycastle.bcpg.ArmoredInputStream; +import org.bouncycastle.bcpg.BCPGInputStream; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPSignature; +import org.wildfly.prospero.ProsperoLogger; + +/** + * Contains a revoke signature + */ +public class PGPRevokeSignature { + private final PGPSignature pgpSignature; + + public PGPRevokeSignature(File revokeKey) throws InvalidCertificateException { + try (FileInputStream fis = new FileInputStream(revokeKey)) { + pgpSignature = new PGPSignature(new BCPGInputStream(new ArmoredInputStream(fis))); + } catch (IOException | PGPException e) { + throw ProsperoLogger.ROOT_LOGGER.invalidCertificate(revokeKey.getAbsolutePath(), e.getMessage(), e); + } + } + + public PGPRevokeSignature(String location, InputStream inputStream) throws InvalidCertificateException { + try { + pgpSignature = new PGPSignature(new BCPGInputStream(new ArmoredInputStream(inputStream))); + } catch (IOException | PGPException e) { + throw ProsperoLogger.ROOT_LOGGER.invalidCertificate(location, e.getMessage(), e); + } + } + + public PGPKeyId getRevokedKeyId() { + return new PGPKeyId(pgpSignature.getKeyID()); + } + + public PGPSignature getPgpSignature() { + return pgpSignature; + } +} diff --git a/prospero-common/src/test/java/org/wildfly/prospero/signatures/KeystoreManagerTest.java b/prospero-common/src/test/java/org/wildfly/prospero/signatures/KeystoreManagerTest.java new file mode 100644 index 000000000..3b3765426 --- /dev/null +++ b/prospero-common/src/test/java/org/wildfly/prospero/signatures/KeystoreManagerTest.java @@ -0,0 +1,91 @@ +/* + * 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.prospero.signatures; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Path; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class KeystoreManagerTest { + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + @Test + public void getKeystoreForTheSamePathTwice_ReturnsSameElement() throws Exception { + final Path keystorePath = temp.newFile("test.crt").toPath(); + final PGPLocalKeystore keystoreOne = KeystoreManager.keystoreFor(keystorePath); + final PGPLocalKeystore keystoreTwo = KeystoreManager.keystoreFor(keystorePath); + + assertThat(unwrap(keystoreOne)).isSameAs(unwrap(keystoreTwo)); + } + + @Test + public void getKeystoreForTheDifferentPath_ReturnsDifferentElement() throws Exception { + final Path keystorePathOne = temp.newFile("test-one.crt").toPath(); + final Path keystorePathTwo = temp.newFile("test-two.crt").toPath(); + final PGPLocalKeystore keystoreOne = KeystoreManager.keystoreFor(keystorePathOne); + final PGPLocalKeystore keystoreTwo = KeystoreManager.keystoreFor(keystorePathTwo); + + assertThat(unwrap(keystoreOne)).isNotSameAs(unwrap(keystoreTwo)); + } + + @Test + public void getKeystoreAfterItWasClosed_ReturnsDifferentElement() throws Exception { + final Path keystorePath = temp.newFile("test.crt").toPath(); + final PGPLocalKeystore keystoreOne = KeystoreManager.keystoreFor(keystorePath); + + keystoreOne.close(); + final PGPLocalKeystore keystoreTwo = KeystoreManager.keystoreFor(keystorePath); + + assertThat(unwrap(keystoreOne)).isNotSameAs(unwrap(keystoreTwo)); + } + + @Test + public void closingKeystoreSecondTimeIsNoop() throws Exception { + final Path keystorePath = temp.newFile("test.crt").toPath(); + final PGPLocalKeystore keystoreOne = KeystoreManager.keystoreFor(keystorePath); + + keystoreOne.close(); + keystoreOne.close(); + final PGPLocalKeystore keystoreTwo = KeystoreManager.keystoreFor(keystorePath); + + assertThat(unwrap(keystoreOne)).isNotSameAs(unwrap(keystoreTwo)); + } + + @Test + public void closingKeystoreDoesntRemoveItIfItIsStillUsed() throws Exception { + final Path keystorePath = temp.newFile("test.crt").toPath(); + final PGPLocalKeystore keystoreOne = KeystoreManager.keystoreFor(keystorePath); + final PGPLocalKeystore keystoreTwo = KeystoreManager.keystoreFor(keystorePath); + + keystoreOne.close(); + final PGPLocalKeystore keystoreThree = KeystoreManager.keystoreFor(keystorePath); + + assertThat(unwrap(keystoreThree)).isSameAs(unwrap(keystoreTwo)); + } + + private static CachedPGPKeystore unwrap(PGPLocalKeystore keystoreThree) { + // very ugly, use only for testing + return (CachedPGPKeystore) ((KeystoreManager.KeystoreWrapper)keystoreThree).getWrapped(); + } +} \ No newline at end of file diff --git a/prospero-common/src/test/java/org/wildfly/prospero/signatures/PGPLocalKeystoreTest.java b/prospero-common/src/test/java/org/wildfly/prospero/signatures/PGPLocalKeystoreTest.java new file mode 100644 index 000000000..e297efa12 --- /dev/null +++ b/prospero-common/src/test/java/org/wildfly/prospero/signatures/PGPLocalKeystoreTest.java @@ -0,0 +1,413 @@ +package org.wildfly.prospero.signatures; + +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.util.encoders.Hex; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.pgpainless.PGPainless; +import org.wildfly.prospero.api.exceptions.OperationException; +import org.wildfly.prospero.utils.TestSignatureUtils; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class PGPLocalKeystoreTest { + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + private PGPLocalKeystore localGpgKeystore; + private Path file; + + @Before + public void setUp() throws Exception { + file = temp.newFolder("keyring-test-folder").toPath(); + localGpgKeystore = KeystoreManager.keystoreFor(file.resolve("store.gpg")); + } + + @After + public void tearDown() { + localGpgKeystore.close(); + } + + // start of initialization tests + + @Test + public void creatingKeyringWithoutKeyDoesntCreateFile() throws Exception { + assertThat(file.resolve("store.gpg")) + .doesNotExist(); + } + + // end of initialization tests + + /* + * start of add key tests + */ + @Test + public void addKeyToKeyring() throws Exception { + final PGPSecretKeyRing generatedKey = TestSignatureUtils.generateSecretKey("Test", "test"); + importKeyRing(generatedKey); + + assertThat(readPublicKeys()) + .map(PGPPublicKey::getFingerprint) + .map(Hex::toHexString) + .containsExactlyElementsOf(getFingerPrints(generatedKey)); + } + + @Test + public void addKeyToExistingKeyring() throws Exception { + final PGPSecretKeyRing generatedKey = TestSignatureUtils.generateSecretKey("Test", "test"); + final PGPSecretKeyRing generatedKeyTwo = TestSignatureUtils.generateSecretKey("Test 2", "test"); + + // add initial key + importKeyRing(generatedKey); + + // re-create Keyring to check that no caching happens + this.localGpgKeystore = KeystoreManager.keystoreFor(file.resolve("store.gpg")); + // and add another key + importKeyRing(generatedKeyTwo); + + assertThat(readPublicKeys()) + .map(PGPPublicKey::getFingerprint) + .map(Hex::toHexString) + .containsExactlyInAnyOrderElementsOf(getFingerPrints(generatedKey, generatedKeyTwo)); + } + + @Test + public void addExistingKeyAgain_ThrowsException() throws Exception { + final PGPSecretKeyRing generatedKey = TestSignatureUtils.generateSecretKey("Test", "test"); + + // add initial key + importKeyRing(generatedKey); + + // try to add another key + assertThatThrownBy(()-> importKeyRing(generatedKey)) + .isInstanceOf(DuplicatedCertificateException.class); + + assertThat(readPublicKeys()) + .map(PGPPublicKey::getFingerprint) + .map(Hex::toHexString) + .containsExactlyInAnyOrderElementsOf(getFingerPrints(generatedKey)); + } + + /* + * end of add key tests + */ + + /* + * start of remove key tests + */ + @Test + public void removeKeyFromKeyring() throws Exception { + final PGPSecretKeyRing generatedKey = TestSignatureUtils.generateSecretKey("Test", "test"); + importKeyRing(generatedKey); + + localGpgKeystore.removeCertificate(new PGPKeyId(generatedKey.getPublicKey().getKeyID())); + + assertNull("Expected the keystore file to not be present", + PGPainless.readKeyRing().keyRing(new FileInputStream(file.resolve("store.gpg").toFile()))); + } + + @Test + public void removeKeyFromKeyringWithTwoKeys() throws Exception { + final PGPSecretKeyRing generatedKey1 = TestSignatureUtils.generateSecretKey("Test", "test"); + importKeyRing(generatedKey1); + + // and import another key + final PGPSecretKeyRing generatedKey2 = TestSignatureUtils.generateSecretKey("Test", "test"); + importKeyRing(generatedKey2); + + localGpgKeystore.removeCertificate(new PGPKeyId(generatedKey1.getPublicKey().getKeyID())); + + assertThat(readPublicKeys()) + .map(PGPPublicKey::getFingerprint) + .map(Hex::toHexString) + .containsExactlyElementsOf(getFingerPrints(generatedKey2)); + } + + @Test + public void removeKeyFromEmptyStore_ReturnsFalse() throws Exception { + final PGPSecretKeyRing generatedKey1 = TestSignatureUtils.generateSecretKey("Test", "test"); + + assertFalse("Removing non-existing cert should return false", + localGpgKeystore.removeCertificate(new PGPKeyId(generatedKey1.getPublicKey().getKeyID()))); + + assertThat(readPublicKeys()) + .map(PGPPublicKey::getFingerprint) + .map(Hex::toHexString) + .isEmpty(); + } + + @Test + public void removeNonExistingKey_ReturnsFalse() throws Exception { + final PGPSecretKeyRing generatedKey1 = TestSignatureUtils.generateSecretKey("Test", "test"); + + importKeyRing(generatedKey1); + + // and import another key + final PGPSecretKeyRing generatedKey2 = TestSignatureUtils.generateSecretKey("Test", "test"); + + assertFalse("Removing non-existing cert should return false", + localGpgKeystore.removeCertificate(new PGPKeyId(generatedKey2.getPublicKey().getKeyID()))); + + assertThat(readPublicKeys()) + .map(PGPPublicKey::getFingerprint) + .map(Hex::toHexString) + .containsExactlyInAnyOrderElementsOf(getFingerPrints(generatedKey1)); + } + + // TODO: remove subkey throws exception + + /* + * end of remove key tests + */ + + /* + * start of import certificate tests + */ + @Test + public void importRevocations() throws Exception { + final File revokeFile = temp.newFile("revoke.gpg"); + final PGPSecretKeyRing generatedKey = TestSignatureUtils.generateSecretKey("Test", "test"); + final PGPSignature revocationSignature = TestSignatureUtils.generateRevocationKeys(generatedKey, "test"); + + importKeyRing(generatedKey); + localGpgKeystore.revokeCertificate(revocationSignature); + + final List publicKeys = readPublicKeys(); + assertThat(publicKeys) + .map(PGPPublicKey::getFingerprint) + .map(Hex::toHexString) + .containsExactlyInAnyOrderElementsOf(getFingerPrints(generatedKey)); + + assertThat(publicKeys).allMatch(PGPLocalKeystoreTest::isRevoked); + } + + @Test + public void multipleKeystoresUse() throws Exception { + final PGPSecretKeyRing generatedKey1 = TestSignatureUtils.generateSecretKey("Test", "test"); + importKeyRing(generatedKey1); + + // and import another key + final PGPSecretKeyRing generatedKey2 = TestSignatureUtils.generateSecretKey("Test", "test"); + try (PGPLocalKeystore localGpgKeystore2 = KeystoreManager.keystoreFor(file.resolve("store.gpg"))) { + localGpgKeystore2.importCertificate(asList(generatedKey2.getPublicKeys())); + } + + localGpgKeystore.removeCertificate(new PGPKeyId(generatedKey1.getPublicKey().getKeyID())); + + assertThat(readPublicKeys()) + .map(PGPPublicKey::getFingerprint) + .map(Hex::toHexString) + .containsExactlyElementsOf(getFingerPrints(generatedKey2)); + } + + /* + * end of import certificate tests + */ + + /* + * start of get certificate tests + */ + + @Test + public void getExistingKey_ReturnCertificate() throws Exception { + final PGPSecretKeyRing generatedKey = TestSignatureUtils.generateSecretKey("Test", "test"); + importKeyRing(generatedKey); + + final PGPPublicKey certificate = localGpgKeystore.getCertificate(new PGPKeyId(generatedKey.getPublicKey().getKeyID())); + + assertThat(certificate) + .isEqualTo(generatedKey.getPublicKey()); + } + + @Test + public void getNonExistingKey_ReturnsNull() throws Exception { + final PGPSecretKeyRing generatedKey = TestSignatureUtils.generateSecretKey("Test", "test"); + importKeyRing(generatedKey); + + final PGPPublicKey certificate = localGpgKeystore.getCertificate(new PGPKeyId(123L)); + + assertThat(certificate) + .isNull(); + } + + @Test + public void getExistingSubkey_ReturnsSubkey() throws Exception { + final PGPSecretKeyRing generatedKey = TestSignatureUtils.generateSecretKey("Test", "test"); + importKeyRing(generatedKey); + + final Iterator publicKeys = generatedKey.getPublicKeys(); + PGPPublicKey subkey = null; + while (publicKeys.hasNext()) { + subkey = publicKeys.next(); + if (!subkey.isMasterKey()) { + break; + } + } + if (subkey == null) { + Assert.fail("The generate key has no subkeys"); + } + final PGPPublicKey certificate = localGpgKeystore.getCertificate(new PGPKeyId(subkey.getKeyID())); + + assertThat(certificate) + .isEqualTo(subkey); + } + + /* + * end of get certificate tests + */ + + /* + * start of list certificates tests + */ + @Test + public void listExistingKey_ReturnsKeys() throws Exception { + final PGPSecretKeyRing generatedKey = TestSignatureUtils.generateSecretKey("Test", "test"); + importKeyRing(generatedKey); + + final Collection certificates = localGpgKeystore.listCertificates(); + + assertThat(certificates) + .containsExactlyInAnyOrderElementsOf(TestSignatureUtils.keyInfoOf(generatedKey)); + } + + @Test + public void listMultipleExistingKeys_ReturnsKeys() throws Exception { + final PGPSecretKeyRing generatedKeyOne = TestSignatureUtils.generateSecretKey("Test", "test"); + final PGPSecretKeyRing generatedKeyTwo = TestSignatureUtils.generateSecretKey("Test", "test"); + importKeyRing(generatedKeyOne); + importKeyRing(generatedKeyTwo); + + final Collection certificates = localGpgKeystore.listCertificates(); + + final Collection expectedKeys = TestSignatureUtils.keyInfoOf(generatedKeyOne); + expectedKeys.addAll(TestSignatureUtils.keyInfoOf(generatedKeyTwo)); + assertThat(certificates) + .containsExactlyInAnyOrderElementsOf(expectedKeys); + } + + @Test + public void listWhenNoKeysArePresent_ReturnsEmptyList() throws Exception { + final Collection certificates = localGpgKeystore.listCertificates(); + + assertThat(certificates) + .isEmpty(); + } + + /* + * end of list certificate tests + */ + + /* + * start of import revoke certificate tests + */ + @Test + public void revokeExistingCertificate() throws Exception { + final PGPSecretKeyRing generatedKey = TestSignatureUtils.generateSecretKey("Test", "test"); + importKeyRing(generatedKey); + final PGPSignature revocationSignature = TestSignatureUtils.generateRevocationKeys(generatedKey, "test"); + + localGpgKeystore.revokeCertificate(revocationSignature); + + final PGPPublicKey certificate = localGpgKeystore.getCertificate(new PGPKeyId(generatedKey.getPublicKey().getKeyID())); + assertTrue("Certificate should have been marked as revoked", certificate.hasRevocation()); + } + + @Test + public void revokeCertificateOnEmptyKeystore() throws Exception { + final PGPSecretKeyRing generatedKey = TestSignatureUtils.generateSecretKey("Test", "test"); + PGPSignature revocationSignature = TestSignatureUtils.generateRevocationKeys(generatedKey, "test"); + + assertThatThrownBy(()->localGpgKeystore.revokeCertificate(revocationSignature)) + .isInstanceOf(NoSuchCertificateException.class) + .hasMessageContaining(new PGPKeyId(generatedKey.getPublicKey().getKeyID()).getHexKeyID()); + } + + @Test + public void revokeNotImportedCertificateEmptyKeystore() throws Exception { + final PGPSecretKeyRing generatedKeyOne = TestSignatureUtils.generateSecretKey("Test", "test"); + final PGPSecretKeyRing generatedKeyTwo = TestSignatureUtils.generateSecretKey("Test", "test"); + importKeyRing(generatedKeyOne); + PGPSignature revocationSignature = TestSignatureUtils.generateRevocationKeys(generatedKeyTwo, "test"); + + assertThatThrownBy(()->localGpgKeystore.revokeCertificate(revocationSignature)) + .isInstanceOf(NoSuchCertificateException.class) + .hasMessageContaining(new PGPKeyId(generatedKeyTwo.getPublicKey().getKeyID()).getHexKeyID()); + } + + /* + * end of import revoke certificate tests + */ + + private List readPublicKeys() throws IOException { + final List keyList = new ArrayList<>(); + if (!Files.exists(file.resolve("store.gpg"))) { + return Collections.emptyList(); + } + final PGPPublicKeyRingCollection pgpKeyRing = PGPainless.readKeyRing().publicKeyRingCollection(new FileInputStream(file.resolve("store.gpg").toFile())); + final Iterator keyRings = pgpKeyRing.getKeyRings(); + while (keyRings.hasNext()) { + final PGPPublicKeyRing keyRing = keyRings.next(); + final Iterator publicKeys = keyRing.getPublicKeys(); + while (publicKeys.hasNext()) { + final PGPPublicKey key = publicKeys.next(); + keyList.add(key); + } + } + return keyList; + } + + private static List getFingerPrints(PGPSecretKeyRing... generatedKeys) { + final List fingerprintList = new ArrayList<>(); + for (PGPSecretKeyRing generatedKey : generatedKeys) { + final Iterator publicKeys = generatedKey.getPublicKeys(); + while (publicKeys.hasNext()) { + final PGPPublicKey key = publicKeys.next(); + fingerprintList.add(Hex.toHexString(key.getFingerprint())); + } + } + return fingerprintList; + } + + private static boolean isRevoked(PGPPublicKey key) { + // only check master keys not subkeys + return !key.isMasterKey() || key.hasRevocation(); + + } + + private void importKeyRing(PGPSecretKeyRing generatedKey) throws IOException, OperationException { + final File keyFile = temp.newFile(); + TestSignatureUtils.exportPublicKeys(generatedKey, keyFile); + localGpgKeystore.importCertificate(asList(generatedKey.getPublicKeys())); + } + + private List asList(Iterator publicKeys) { + final ArrayList res = new ArrayList<>(); + while (publicKeys.hasNext()) { + res.add(publicKeys.next()); + } + return res; + } +} \ No newline at end of file diff --git a/prospero-common/src/test/java/org/wildfly/prospero/utils/TestSignatureUtils.java b/prospero-common/src/test/java/org/wildfly/prospero/utils/TestSignatureUtils.java new file mode 100644 index 000000000..21e730052 --- /dev/null +++ b/prospero-common/src/test/java/org/wildfly/prospero/utils/TestSignatureUtils.java @@ -0,0 +1,165 @@ +package org.wildfly.prospero.utils; + +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.encoders.Hex; +import org.bouncycastle.util.io.Streams; +import org.pgpainless.PGPainless; +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.protection.SecretKeyRingProtector; +import org.pgpainless.sop.SOPImpl; +import org.pgpainless.util.Passphrase; +import org.wildfly.prospero.signatures.PGPKeyId; +import org.wildfly.prospero.signatures.PGPPublicKeyInfo; +import sop.SOP; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +public class TestSignatureUtils { + + /** + * Generate key ring with private/public key pair + * + * @return + * @throws Exception + * @param userId + * @param password + */ + public static PGPSecretKeyRing generateSecretKey(String userId, String password) throws Exception { + return PGPainless.generateKeyRing().modernKeyRing(userId, password); + } + + /** + * Sign {@code originalFile} using private key found in the {@code keyRing}. The detached signature is stored + * next to {@code originalFile} with ".asc" suffix. + * + * @param keyRing + * @param originalFile + * @param pass + * @return + * @throws Exception + */ + public static Long signFile(PGPSecretKeyRing keyRing, Path originalFile, String pass) throws Exception { + EncryptionStream encryptionStream = null; + try { + encryptionStream = getEncryptionStreamWithSigning(keyRing, pass); + try (InputStream fIn = new FileInputStream(originalFile.toFile())) { + Streams.pipeAll(fIn, encryptionStream); + } + } finally { + // can't use try-with-resources - the encryptionStream has to be close before next step, but we still need access to it + if (encryptionStream != null) { + encryptionStream.close(); + } + } + + final Path signatureFilePath = originalFile.getParent().resolve(originalFile.getFileName().toString() + ".asc"); + + try(FileOutputStream fos = new FileOutputStream(signatureFilePath.toFile()); + 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 pgpSignature.getKeyID(); + } + } + } + return null; + } + + private static EncryptionStream getEncryptionStreamWithSigning(PGPSecretKeyRing keyRing, String pass) throws PGPException, IOException { + return PGPainless.encryptAndOrSign() + .onOutputStream(new ByteArrayOutputStream()) + .withOptions(ProducerOptions.sign(SigningOptions.get() + .addDetachedSignature( + SecretKeyRingProtector.unlockAnyKeyWith(Passphrase.fromPassword(pass)), + keyRing))); + } + + public static void exportPublicKeys(PGPSecretKeyRing pgpSecretKey, File targetFile) throws IOException { + final List pubKeyList = new ArrayList<>(); + final Iterator publicKeys = pgpSecretKey.getPublicKeys(); + publicKeys.forEachRemaining(pubKeyList::add); + final PGPPublicKeyRing pubKeyRing = new PGPPublicKeyRing(pubKeyList); + try (OutputStream outStream = new ArmoredOutputStream(new FileOutputStream(targetFile))) { + pubKeyRing.encode(outStream, true); + } + } + + public static Long exportRevocationKeys(PGPSecretKeyRing pgpSecretKey, File targetFile, String password) throws IOException { + final SOP sop = new SOPImpl(); + final PGPPublicKeyRing pgpPublicKeys = PGPainless.readKeyRing().publicKeyRing(sop.revokeKey() + .withKeyPassword(password) + .keys(pgpSecretKey.getEncoded()).getInputStream()); + + final Iterator signatures = pgpPublicKeys.getPublicKey().getSignaturesOfType(PGPSignature.KEY_REVOCATION); + while(signatures.hasNext()) { + final PGPSignature signature = signatures.next(); + try (ArmoredOutputStream outStream = new ArmoredOutputStream(new FileOutputStream(targetFile))) { + signature.encode(outStream, true); + return signature.getKeyID(); + } + } + return null; + } + + public static PGPSignature generateRevocationKeys(PGPSecretKeyRing pgpSecretKey, String password) throws IOException { + final SOP sop = new SOPImpl(); + final PGPPublicKeyRing pgpPublicKeys = PGPainless.readKeyRing().publicKeyRing(sop.revokeKey() + .withKeyPassword(password) + .keys(pgpSecretKey.getEncoded()).getInputStream()); + + final Iterator signatures = pgpPublicKeys.getPublicKey().getSignaturesOfType(PGPSignature.KEY_REVOCATION); + while(signatures.hasNext()) { + return signatures.next(); + } + return null; + } + + public static Collection keyInfoOf(PGPSecretKeyRing secretKey) { + final ArrayList res = new ArrayList<>(); + final Iterator publicKeys = secretKey.getPublicKeys(); + while (publicKeys.hasNext()) { + final PGPPublicKey key = publicKeys.next(); + res.add(keyInfoOf(PGPPublicKeyInfo.Status.TRUSTED, key)); + } + return res; + } + + public static PGPPublicKeyInfo keyInfoOf(PGPPublicKeyInfo.Status status, PGPPublicKey publicKey) { + final Iterator userIDs = publicKey.getUserIDs(); + final ArrayList userIDsArray = new ArrayList<>(); + while (userIDs.hasNext()) { + userIDsArray.add(userIDs.next()); + } + final LocalDateTime creationDate = publicKey.getCreationTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); + final LocalDateTime expiryDate = publicKey.getValidSeconds() > 0 ? creationDate.plusSeconds(publicKey.getValidSeconds()) : null; + return new PGPPublicKeyInfo(new PGPKeyId(publicKey.getKeyID()), status, + Hex.toHexString(publicKey.getFingerprint()).toUpperCase(Locale.ROOT), userIDsArray, + creationDate, expiryDate + ); + } +} From b06b6de67871ce1d92019c958af309e4128c51c0 Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Thu, 12 Sep 2024 14:36:23 +0100 Subject: [PATCH 04/11] Adding certificate store operations --- integration-tests/pom.xml | 7 + .../CertificateActionsTestCase.java | 412 ++++++++++++++++++ .../prospero/test/CertificateUtils.java | 220 ++++++++++ prospero-cli/pom.xml | 5 + .../wildfly/prospero/cli/ActionFactory.java | 5 + .../org/wildfly/prospero/cli/CliConsole.java | 6 + .../org/wildfly/prospero/cli/CliMain.java | 5 + .../org/wildfly/prospero/cli/CliMessages.java | 84 ++++ .../prospero/cli/commands/CliConstants.java | 5 + .../certificate/CertificateAddCommand.java | 80 ++++ .../certificate/CertificateListCommand.java | 64 +++ .../certificate/CertificateRemoveCommand.java | 84 ++++ .../certificate/CertificatesCommand.java | 20 + .../cli/commands/certificate/KeyPrinter.java | 52 +++ .../commands/channel/ChannelAddCommand.java | 11 +- .../main/resources/UsageMessages.properties | 32 +- .../CertificateAddCommandTest.java | 117 +++++ .../CertificateListCommandTest.java | 111 +++++ .../CertificateRemoveCommandTest.java | 186 ++++++++ .../prospero/test/CertificateUtils.java | 220 ++++++++++ .../prospero/actions/CertificateAction.java | 133 ++++++ 21 files changed, 1857 insertions(+), 2 deletions(-) create mode 100644 integration-tests/src/test/java/org/wildfly/prospero/it/signatures/CertificateActionsTestCase.java create mode 100644 integration-tests/src/test/java/org/wildfly/prospero/test/CertificateUtils.java create mode 100644 prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificateAddCommand.java create mode 100644 prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificateListCommand.java create mode 100644 prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificateRemoveCommand.java create mode 100644 prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificatesCommand.java create mode 100644 prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/KeyPrinter.java create mode 100644 prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/certificate/CertificateAddCommandTest.java create mode 100644 prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/certificate/CertificateListCommandTest.java create mode 100644 prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/certificate/CertificateRemoveCommandTest.java create mode 100644 prospero-cli/src/test/java/org/wildfly/prospero/test/CertificateUtils.java create mode 100644 prospero-common/src/main/java/org/wildfly/prospero/actions/CertificateAction.java diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 97c1ea280..68690c461 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -53,6 +53,13 @@ xnio-nio test + + org.pgpainless + pgpainless-core + 1.6.1 + test + + diff --git a/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/CertificateActionsTestCase.java b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/CertificateActionsTestCase.java new file mode 100644 index 000000000..649e70ef2 --- /dev/null +++ b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/CertificateActionsTestCase.java @@ -0,0 +1,412 @@ +/* + * 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.prospero.it.signatures; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.Locale; + +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.util.encoders.Hex; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.wildfly.prospero.signatures.DuplicatedCertificateException; +import org.wildfly.prospero.signatures.PGPKeyId; +import org.wildfly.prospero.signatures.PGPPublicKeyInfo; +import org.wildfly.prospero.ProsperoLogger; +import org.wildfly.prospero.actions.CertificateAction; +import org.wildfly.prospero.signatures.PGPRevokeSignature; +import org.wildfly.prospero.signatures.PGPPublicKey; +import org.wildfly.prospero.signatures.InvalidCertificateException; +import org.wildfly.prospero.api.exceptions.MetadataException; +import org.wildfly.prospero.signatures.NoSuchCertificateException; +import org.wildfly.prospero.api.exceptions.OperationException; +import org.wildfly.prospero.metadata.ProsperoMetadataUtils; +import org.wildfly.prospero.test.CertificateUtils; + +public class CertificateActionsTestCase { + + @ClassRule + public static TemporaryFolder classTemp = new TemporaryFolder(); + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + private Path serverPath; + private static PGPSecretKeyRing pgpSecretKeysOne; + private static PGPSecretKeyRing pgpSecretKeysTwo; + private static File pubCertOne; + private static File pubCertTwo; + private CertificateAction certificateAction; + private Path keyringPath; + private static File revocationCrtOne; + + @BeforeClass + public static void classSetUp() throws Exception { + // generate the keys once to speed up the tests + pgpSecretKeysOne = CertificateUtils.generatePrivateKey(); + pgpSecretKeysTwo = CertificateUtils.generatePrivateKey(); + pubCertOne = CertificateUtils.exportPublicCertificate(pgpSecretKeysOne, classTemp.getRoot().toPath().resolve("pub-one.crt").toFile()); + pubCertTwo = CertificateUtils.exportPublicCertificate(pgpSecretKeysTwo, classTemp.getRoot().toPath().resolve("pub-two.crt").toFile()); + revocationCrtOne = CertificateUtils.generateRevocationSignature(pgpSecretKeysOne, classTemp.newFile("revoke.crt")); + } + + @Before + public void setUp() throws Exception { + serverPath = mockServer(); + certificateAction = new CertificateAction(serverPath); + + keyringPath = serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg"); + } + + private Path mockServer() throws IOException { + final Path serverPath = temp.newFolder("server").toPath(); + Files.createDirectory(serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR)); + return serverPath; + } + + @After + public void tearDown() throws Exception { + certificateAction.close(); + } + + // add certificate + // add new certificate - no keystore - success + @Test + public void addNewCertificateNoKeystore_PresentInKeystore() throws Exception { + certificateAction.importCertificate(new PGPPublicKey(pubCertOne)); + + CertificateUtils.assertKeystoreContainsOnly(keyringPath, keyID(pgpSecretKeysOne)); + } + + // add new certificate - existing keystore - success + @Test + public void addNewCertificateToExistingKeystore_PresentInKeystore() throws Exception { + certificateAction.importCertificate(new PGPPublicKey(pubCertOne)); + certificateAction.importCertificate(new PGPPublicKey(pubCertTwo)); + + CertificateUtils.assertKeystoreContainsOnly(keyringPath, + keyID(pgpSecretKeysOne), + keyID(pgpSecretKeysTwo) + ); + } + + // add an invalid certificate - error + @Test + public void addInvalidCertificateToKeystore_ThrowsException() throws Exception { + final File invalidCert = Files.writeString(temp.getRoot().toPath().resolve("invalid-cert.crt"), + "i'm not a cert").toFile(); + + assertThatThrownBy(() -> certificateAction.importCertificate(new PGPPublicKey(invalidCert))) + .isInstanceOf(InvalidCertificateException.class) + .hasMessageContaining(ProsperoLogger.ROOT_LOGGER.invalidCertificate(invalidCert.getAbsolutePath(), "", null).getMessage()); + + if (Files.exists(keyringPath)) { + CertificateUtils.assertKeystoreIsEmpty(keyringPath); + } + } + + // add an already added certificate - error + @Test + public void addExistingCertificateToKeystore_ThrowsException() throws Exception { + certificateAction.importCertificate(new PGPPublicKey(pubCertOne)); + + final long existingKeyID = keyID(pgpSecretKeysOne); + assertThatThrownBy(() -> certificateAction.importCertificate(new PGPPublicKey(pubCertOne))) + .isInstanceOf(DuplicatedCertificateException.class) + .hasMessageContaining(ProsperoLogger.ROOT_LOGGER.certificateAlreadyExists( + new PGPKeyId(existingKeyID).getHexKeyID()).getMessage()); + + CertificateUtils.assertKeystoreContainsOnly(keyringPath, existingKeyID); + } + + // add a certificate to non-writable keystore - error + + @Test + public void addCertificateToBrokenKeystore_ThrowsException() throws Exception { + try { + Files.createFile(keyringPath); + assertTrue("Unable to mark keyring as read-only", keyringPath.toFile().setReadOnly()); + assertThatThrownBy(() -> certificateAction.importCertificate(new PGPPublicKey(pubCertOne))) + .isInstanceOf(OperationException.class) + .hasMessageContaining(ProsperoLogger.ROOT_LOGGER.unableToWriteKeystore(keyringPath, "", null).getMessage()) + .hasCauseInstanceOf(IOException.class); + } finally { + keyringPath.toFile().setWritable(true); + } + + } + // list certificate + // when no certificates + + @Test + public void listCertificatesEmptyKeystore_EmptyList() throws Exception { + // import and remove should result in an empty keyring file + certificateAction.importCertificate(new PGPPublicKey(pubCertOne)); + long keyID = keyID(pgpSecretKeysOne); + certificateAction.removeCertificate(new PGPKeyId(keyID)); + + final Collection keys = certificateAction.listCertificates(); + + assertThat(keys) + .isEmpty(); + } + // when no keystore + + @Test + public void listCertificatesNoKeystore_EmptyList() throws Exception { + final Collection keys = certificateAction.listCertificates(); + + assertThat(keys) + .isEmpty(); + } + + // when one certificate + @Test + public void listCertificates() throws Exception { + certificateAction.importCertificate(new PGPPublicKey(pubCertOne)); + certificateAction.importCertificate(new PGPPublicKey(pubCertTwo)); + + final Collection keys = certificateAction.listCertificates(); + + assertThat(keys) + .containsExactlyInAnyOrder( + keyInfoOf(pgpSecretKeysOne), + keyInfoOf(pgpSecretKeysTwo) + ); + } + + // when certificate is revoked + @Test + public void listRevokedCertificate() throws Exception { + final File revokeKey = CertificateUtils.generateRevokedKey(pgpSecretKeysOne, temp.newFile("revoke.crt")); + certificateAction.importCertificate(new PGPPublicKey(revokeKey)); + + final Collection keys = certificateAction.listCertificates(); + + assertThat(keys) + .containsExactlyInAnyOrder( + keyInfoOf(pgpSecretKeysOne, PGPPublicKeyInfo.Status.REVOKED) + ); + } + + // when certificate is expired + @Test + public void listExpiredCertificate() throws Exception { + final PGPSecretKeyRing expiredKey = CertificateUtils.generateExpiredPrivateKey(); + final File expiredKeyCert = CertificateUtils.exportPublicCertificate(expiredKey, temp.newFile("expired.cert")); + certificateAction.importCertificate(new PGPPublicKey(expiredKeyCert)); + + CertificateUtils.waitUntilExpires(expiredKey); + + final Collection keys = certificateAction.listCertificates(); + + assertThat(keys) + .containsExactlyInAnyOrder( + keyInfoOf(expiredKey, PGPPublicKeyInfo.Status.EXPIRED) + ); + } + + // remove certificate + // when matching certificate - success + @Test + public void removeOnlyCertificatePresentInKeystore() throws Exception { + certificateAction.importCertificate(new PGPPublicKey(pubCertOne)); + + long keyID = keyID(pgpSecretKeysOne); + certificateAction.removeCertificate(new PGPKeyId(keyID)); + + CertificateUtils.assertKeystoreIsEmpty(keyringPath); + } + + // when no certificates - error + @Test + public void removeWhenNoCertificatesArePresentInKeystore() throws Exception { + // import and remove should result in an empty keyring file + certificateAction.importCertificate(new PGPPublicKey(pubCertOne)); + long keyID2 = keyID(pgpSecretKeysOne); + certificateAction.removeCertificate(new PGPKeyId(keyID2)); + + long keyID = keyID(pgpSecretKeysOne); + assertThatThrownBy(()-> { + long keyID1 = keyID(pgpSecretKeysOne); + certificateAction.removeCertificate(new PGPKeyId(keyID1)); + }) + .isInstanceOf(NoSuchCertificateException.class) + .hasMessageContaining(ProsperoLogger.ROOT_LOGGER.noSuchCertificate(new PGPKeyId(keyID).getHexKeyID()).getMessage()); + + CertificateUtils.assertKeystoreIsEmpty(keyringPath); + } + + // when no keystore - error + @Test + public void removeWhenKeystoreDoesntExist() throws Exception { + long keyID = keyID(pgpSecretKeysOne); + assertThatThrownBy(()-> { + long keyID1 = keyID(pgpSecretKeysOne); + certificateAction.removeCertificate(new PGPKeyId(keyID1)); + }) + .isInstanceOf(NoSuchCertificateException.class) + .hasMessageContaining(ProsperoLogger.ROOT_LOGGER.noSuchCertificate(new PGPKeyId(keyID).getHexKeyID()).getMessage()); + + assertThat(keyringPath) + .doesNotExist(); + } + + // when no matching certificates - error + @Test + public void removeWhenTheCertificateIsNotPresentInKeystore() throws Exception { + // import certOne and try to remote certTwo + certificateAction.importCertificate(new PGPPublicKey(pubCertOne)); + + long keyID = keyID(pgpSecretKeysTwo); + assertThatThrownBy(()-> { + long keyID1 = keyID(pgpSecretKeysTwo); + certificateAction.removeCertificate(new PGPKeyId(keyID1)); + }) + .isInstanceOf(NoSuchCertificateException.class) + .hasMessageContaining(ProsperoLogger.ROOT_LOGGER.noSuchCertificate(new PGPKeyId(keyID).getHexKeyID()).getMessage()); + + CertificateUtils.assertKeystoreContains(keyringPath, keyID(pgpSecretKeysOne)); + } + + // revoke certificate + // when matching certificate - success + @Test + public void revokeCertificateMarksItRevoked() throws Exception { + certificateAction.importCertificate(new PGPPublicKey(pubCertOne)); + + certificateAction.revokeCertificate(new PGPRevokeSignature(revocationCrtOne)); + + final Collection keys = certificateAction.listCertificates(); + assertThat(keys) + .containsExactlyInAnyOrder( + keyInfoOf(pgpSecretKeysOne, PGPPublicKeyInfo.Status.REVOKED) + ); + } + + // when no certificates - error + @Test + public void revokeCertificateWhenKeystoreIsEmpty_NoSuchCertificateError() throws Exception { + certificateAction.importCertificate(new PGPPublicKey(pubCertOne)); + long keyID1 = keyID(pgpSecretKeysOne); + certificateAction.removeCertificate(new PGPKeyId(keyID1)); + + long keyID = keyID(pgpSecretKeysOne); + assertThatThrownBy(()->certificateAction.revokeCertificate(new PGPRevokeSignature(revocationCrtOne))) + .isInstanceOf(NoSuchCertificateException.class) + .hasMessageContaining(ProsperoLogger.ROOT_LOGGER.noSuchCertificate(new PGPKeyId(keyID).getHexKeyID()).getMessage()); + } + + // when no matching certificates - error + @Test + public void revokeCertificateWhenCertificateIsNotPresent_NoSuchCertificateError() throws Exception { + certificateAction.importCertificate(new PGPPublicKey(pubCertTwo)); + + long keyID = keyID(pgpSecretKeysOne); + assertThatThrownBy(()->certificateAction.revokeCertificate(new PGPRevokeSignature(revocationCrtOne))) + .isInstanceOf(NoSuchCertificateException.class) + .hasMessageContaining(ProsperoLogger.ROOT_LOGGER.noSuchCertificate(new PGPKeyId(keyID).getHexKeyID()).getMessage()); + + CertificateUtils.assertKeystoreContainsOnly(keyringPath, keyID(pgpSecretKeysTwo)); + } + + // when the certificate is invalid + @Test + public void revokingWithInvalidCertificate_ThrowsException() throws Exception { + File revocationSignature = temp.newFile("revocation.sig"); + Files.writeString(revocationSignature.toPath(), "I'm not a certificate"); + + assertThatThrownBy(()->certificateAction.revokeCertificate(new PGPRevokeSignature(revocationSignature))) + .isInstanceOf(InvalidCertificateException.class); + } + + @Test + public void createActionWithInvalidKeystoreFile_ThrowsException() throws Exception { + Files.writeString(keyringPath, "I'm not a kerying collection"); + + // need to close current certificateAction to remove cached keystore + certificateAction.close(); + assertThatThrownBy(() -> new CertificateAction(serverPath).close()) + .isInstanceOf(MetadataException.class) + .hasMessageContaining(ProsperoLogger.ROOT_LOGGER.unableToReadKeyring(keyringPath, "", null).getMessage()); + } + + // get certificate + // get from non-existing keyring - return null + @Test + public void getCertificateEmptyKeystore_ReturnsNull() throws Exception { + long keyID = pgpSecretKeysOne.getPublicKey().getKeyID(); + assertThat(certificateAction.getCertificate(new PGPKeyId(keyID))) + .isNull(); + } + + // get non-existing certificate - return null + @Test + public void getNonExistingCertificate_ReturnsNull() throws Exception { + certificateAction.importCertificate(new PGPPublicKey(pubCertTwo)); + long keyID = pgpSecretKeysOne.getPublicKey().getKeyID(); + assertThat(certificateAction.getCertificate(new PGPKeyId(keyID))) + .isNull(); + } + + // get an existing certificate - return cert + @Test + public void getExistingCertificate_ReturnsKeyInfo() throws Exception { + certificateAction.importCertificate(new PGPPublicKey(pubCertOne)); + long keyID = pgpSecretKeysOne.getPublicKey().getKeyID(); + assertThat(certificateAction.getCertificate(new PGPKeyId(keyID))) + .isEqualTo(keyInfoOf(pgpSecretKeysOne, PGPPublicKeyInfo.Status.TRUSTED)); + } + + private static long keyID(PGPSecretKeyRing pgpSecretKeysOne) { + return pgpSecretKeysOne.getPublicKey().getKeyID(); + } + + private static PGPPublicKeyInfo keyInfoOf(PGPSecretKeyRing key) { + return keyInfoOf(key, PGPPublicKeyInfo.Status.TRUSTED); + } + + private static PGPPublicKeyInfo keyInfoOf(PGPSecretKeyRing key, PGPPublicKeyInfo.Status status) { + final Iterator userIDs = key.getPublicKey().getUserIDs(); + final ArrayList userIDsArray = new ArrayList<>(); + while (userIDs.hasNext()) { + userIDsArray.add(userIDs.next()); + } + final LocalDateTime creationDate = key.getPublicKey().getCreationTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); + long keyID = keyID(key); + return new PGPPublicKeyInfo(new PGPKeyId(keyID), status, + Hex.toHexString(key.getPublicKey().getFingerprint()).toUpperCase(Locale.ROOT), userIDsArray, + creationDate, creationDate.plusSeconds(key.getPublicKey().getValidSeconds()) + ); + } +} diff --git a/integration-tests/src/test/java/org/wildfly/prospero/test/CertificateUtils.java b/integration-tests/src/test/java/org/wildfly/prospero/test/CertificateUtils.java new file mode 100644 index 000000000..274c632b4 --- /dev/null +++ b/integration-tests/src/test/java/org/wildfly/prospero/test/CertificateUtils.java @@ -0,0 +1,220 @@ +/* + * 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.prospero.test; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.security.InvalidAlgorithmParameterException; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Date; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; +import java.util.stream.Collectors; + +import org.assertj.core.api.Condition; +import org.bouncycastle.bcpg.ArmoredOutputStream; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.util.io.Streams; +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.spi.SignatureResult; +import org.wildfly.channel.spi.SignatureValidator; +import org.wildfly.prospero.signatures.PGPKeyId; + +public class CertificateUtils { + + public static PGPSecretKeyRing generatePrivateKey() throws InvalidAlgorithmParameterException, NoSuchAlgorithmException, PGPException { + return PGPainless.generateKeyRing().simpleRsaKeyRing("Test ", RsaLength._4096); + } + + public static PGPSecretKeyRing generateExpiredPrivateKey() throws InvalidAlgorithmParameterException, NoSuchAlgorithmException, PGPException { + // for some reason sometimes it generates a non-expiring cert + PGPSecretKeyRing expiredPrivateKey = null; + int regenCounter = 0; + do { + if (regenCounter++ > 10) { + throw new RuntimeException("Unable to generate expired certificate"); + } + try { + expiredPrivateKey = doGenereteExpiredPrivateKey(); + } catch (IllegalArgumentException e) { + // sometimes the exception is thrown when setting the expiry date, ignore it and retry + e.printStackTrace(); + } + } while (expiredPrivateKey == null || expiredPrivateKey.getPublicKey().getValidSeconds() <= 0); + return expiredPrivateKey; + } + + private static PGPSecretKeyRing doGenereteExpiredPrivateKey() throws NoSuchAlgorithmException, PGPException, InvalidAlgorithmParameterException { + return 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() + 2_000)) + .build(); + } + + public static void assertKeystoreContainsOnly(Path keystoreFile, long... expectedKeyIds) throws IOException { + final HashSet actualKeyIds = getKeyIds(keystoreFile); + + assertThat(actualKeyIds) + .containsExactlyInAnyOrderElementsOf(Arrays.stream(expectedKeyIds).boxed() + .map(PGPKeyId::new) + .collect(Collectors.toList())); + } + + public static void assertKeystoreContains(Path keystoreFile, long keyID) throws IOException { + final HashSet keyIds = getKeyIds(keystoreFile); + + assertThat(keyIds).contains(new PGPKeyId(keyID)); + } + + public static void assertKeystoreIsEmpty(Path keystoreFile) throws IOException { + final HashSet keyIds = getKeyIds(keystoreFile); + + assertThat(keyIds).isEmpty(); + } + + private static HashSet getKeyIds(Path keystoreFile) throws IOException { + final PGPPublicKeyRingCollection pgpPublicKeys = PGPainless.readKeyRing().publicKeyRingCollection(new FileInputStream(keystoreFile.toFile())); + + final HashSet keyIds = new HashSet<>(); + final Iterator keyRings = pgpPublicKeys.getKeyRings(); + while (keyRings.hasNext()) { + final Iterator publicKeys = keyRings.next().getPublicKeys(); + while (publicKeys.hasNext()) { + keyIds.add(new PGPKeyId(publicKeys.next().getKeyID())); + } + } + return keyIds; + } + + public static File exportPublicCertificate(PGPSecretKeyRing keyRing, File publicCertFile) throws IOException { + // export the public certificate + try (ArmoredOutputStream outStream = new ArmoredOutputStream(new FileOutputStream(publicCertFile))) { + keyRing.getPublicKey().encode(outStream); + } + return publicCertFile; + } + + public static File generateRevocationSignature(PGPSecretKeyRing pgpValidKeys, File publicCertFile) throws PGPException, IOException { + final PGPSecretKeyRing revokedKeyRing = PGPainless.modifyKeyRing(pgpValidKeys) + .revoke(new UnprotectedKeysProtector(), + RevocationAttributes + .createKeyRevocation() + .withReason(RevocationAttributes.Reason.KEY_COMPROMISED) + .withDescription("The key is revoked")) + .done(); + final Iterator signatures = revokedKeyRing.getPublicKey().getSignatures(); + while (signatures.hasNext()) { + final PGPSignature signature = signatures.next(); + if (signature.getSignatureType() == PGPSignature.KEY_REVOCATION) { + try (ArmoredOutputStream outStream = new ArmoredOutputStream(new FileOutputStream(publicCertFile))) { + signature.encode(outStream); + } + } + } + + return publicCertFile; + } + + public static File generateRevokedKey(PGPSecretKeyRing pgpValidKeys, File publicCertFile) throws PGPException, IOException { + final PGPSecretKeyRing revokedKeyRing = PGPainless.modifyKeyRing(pgpValidKeys) + .revoke(new UnprotectedKeysProtector(), + RevocationAttributes + .createKeyRevocation() + .withReason(RevocationAttributes.Reason.KEY_COMPROMISED) + .withDescription("The key is revoked")) + .done(); + return exportPublicCertificate(revokedKeyRing, publicCertFile); + } + + public static File signFile(Path file, File signatureFile, PGPSecretKeyRing pgpSecretKeys) throws PGPException, IOException { + final SigningOptions signOptions = SigningOptions.get() + .addDetachedSignature(new UnprotectedKeysProtector(), pgpSecretKeys); + + final EncryptionStream encryptionStream = PGPainless.encryptAndOrSign() + .onOutputStream(new FileOutputStream(signatureFile)) + .withOptions(ProducerOptions.sign(signOptions)); + + Streams.pipeAll(new FileInputStream(file.toFile()), 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; + } + + public static Condition result(SignatureValidator.SignatureException exception, SignatureResult.Result expectedResult) { + return new Condition<>(e -> exception.getSignatureResult().getResult() == expectedResult, + "Expected exception state %s but was %s", expectedResult, exception.getSignatureResult().getResult()); + } + + public static boolean isExpired(PGPPublicKey publicKey) { + if (publicKey.getValidSeconds() == 0) { + System.out.println(publicKey.getValidSeconds()); + return false; + } else { + final Instant expiry = Instant.from(publicKey.getCreationTime().toInstant().plus(publicKey.getValidSeconds(), ChronoUnit.SECONDS)); + return expiry.isBefore(Instant.now()); + } + } + + public static void waitUntilExpires(PGPSecretKeyRing expiredKeys) throws InterruptedException { + final long start = System.currentTimeMillis(); + final long maxWait = 60_000; + while (!CertificateUtils.isExpired(expiredKeys.getPublicKey())) { + if (System.currentTimeMillis() > start + maxWait) { + throw new RuntimeException(String.format("The certificate %s has not expired in %d seconds", + new PGPKeyId(expiredKeys.getPublicKey().getKeyID()).getHexKeyID(), maxWait)); + } + //noinspection BusyWait + Thread.sleep(100); + } + } +} diff --git a/prospero-cli/pom.xml b/prospero-cli/pom.xml index 058f73e52..914f04d60 100644 --- a/prospero-cli/pom.xml +++ b/prospero-cli/pom.xml @@ -100,6 +100,11 @@ system-rules test + + org.pgpainless + pgpainless-core + test + diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/ActionFactory.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/ActionFactory.java index 33fcae4da..51387c618 100644 --- a/prospero-cli/src/main/java/org/wildfly/prospero/cli/ActionFactory.java +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/ActionFactory.java @@ -23,6 +23,7 @@ import org.jboss.galleon.ProvisioningException; import org.wildfly.channel.Repository; import org.wildfly.prospero.actions.ApplyCandidateAction; +import org.wildfly.prospero.actions.CertificateAction; import org.wildfly.prospero.actions.FeaturesAddAction; import org.wildfly.prospero.actions.SubscribeNewServerAction; import org.wildfly.prospero.api.Console; @@ -85,4 +86,8 @@ public FeaturesAddAction featuresAddAction(Path installationDir, MavenOptions ma public SubscribeNewServerAction subscribeNewServerAction(MavenOptions mvnOptions, Console console) throws ProvisioningException { return new SubscribeNewServerAction(mvnOptions, console); } + + public CertificateAction certificateAction(Path installationDir) throws MetadataException { + return new CertificateAction(installationDir); + } } diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliConsole.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliConsole.java index b984ef027..19b1e68f3 100644 --- a/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliConsole.java +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliConsole.java @@ -208,9 +208,11 @@ public boolean confirm(String prompt, String accepted, String cancelled) { while (true) { String resp = sc.nextLine(); if (resp.equalsIgnoreCase(CliMessages.MESSAGES.noShortcut()) || resp.isBlank()) { + emptyLine(); println(cancelled); return false; } else if (resp.equalsIgnoreCase(CliMessages.MESSAGES.yesShortcut())) { + emptyLine(); println(accepted); return true; } else { @@ -243,6 +245,10 @@ public void error(String message, String... args) { getErrOut().println(String.format(message, (Object[]) args)); } + public void emptyLine() { + println(""); + } + @Override public void println(String text) { if (text == null) { diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliMain.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliMain.java index 40d2804bf..e39a51bee 100644 --- a/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliMain.java +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliMain.java @@ -33,6 +33,7 @@ import org.wildfly.prospero.cli.commands.PrintLicensesCommand; import org.wildfly.prospero.cli.commands.RevertCommand; import org.wildfly.prospero.cli.commands.UpdateCommand; +import org.wildfly.prospero.cli.commands.certificate.CertificatesCommand; import org.wildfly.prospero.cli.commands.channel.ChannelAddCommand; import org.wildfly.prospero.cli.commands.channel.ChannelInitializeCommand; import org.wildfly.prospero.cli.commands.channel.ChannelPromoteCommand; @@ -102,6 +103,10 @@ public static CommandLine createCommandLine(CliConsole console, String[] args, A commandLine.addSubcommand(featuresCommand); featuresCommand.addSubCommands(commandLine); + final CertificatesCommand certsCommand = new CertificatesCommand(console, actionFactory); + commandLine.addSubcommand(certsCommand); + certsCommand.addSubCommands(commandLine); + commandLine.setUsageHelpAutoWidth(true); final boolean isVerbose = Arrays.stream(args).anyMatch(s -> s.equals(CliConstants.VV) || s.equals(CliConstants.VERBOSE)); final CommandLine.IParameterExceptionHandler rootParameterExceptionHandler = commandLine.getParameterExceptionHandler(); diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliMessages.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliMessages.java index 11684f402..8e128e38f 100644 --- a/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliMessages.java +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliMessages.java @@ -716,4 +716,88 @@ default String candidateApplyRollbackSuccess() { default String candidateApplyRollbackFailure(Path backup) { return format(bundle.getString("prospero.candidate.apply.error.rollback_error.desc"), backup); } + + default String certificateImportHeader() { + return bundle.getString("prospero.certificate.import.header"); + } + + default String certificateImportConfirmation() { + return bundle.getString("prospero.certificate.import.confirm_prompt"); + } + + default String certificateImportConfirmed(String keyId) { + return format(bundle.getString("prospero.certificate.import.confirmed"), keyId); + } + + default String certificateImportCancelled(String keyId) { + return format(bundle.getString("prospero.certificate.import.cancelled"), keyId); + } + + default ArgumentParsingException certificateNonExistingFilePath(Path certFile) { + return new ArgumentParsingException(format(bundle.getString("prospero.certificate.import.cert_file_doesnt_exist"), certFile)); + } + + default String noPublicKeysHeader() { + return bundle.getString("prospero.certificate.list.no_keys"); + } + + default String publicKeysListHeader() { + return bundle.getString("prospero.certificate.list.header"); + } + + default String publicKeyIdLabel() { + return bundle.getString("prospero.certificate.keyId.label"); + } + + default String publicKeyFingerprintLabel() { + return bundle.getString("prospero.certificate.fingerprint.label"); + } + + default String publicKeyTrustStatusLabel() { + return bundle.getString("prospero.certificate.trust_status.label"); + } + + default String publicKeyUserIdsLabel() { + return bundle.getString("prospero.certificate.user_ids.label"); + } + + default String publicKeyCreateTimeLabel() { + return bundle.getString("prospero.certificate.created_time.label"); + } + + default String publicKeyExpiresTimeLabel() { + return bundle.getString("prospero.certificate.expires_time.label"); + } + + default String noSuchCertificate(String keyID) { + return format(bundle.getString("prospero.certificate.remove.no_such_key"), keyID); + } + + default String certificateRemoveHeader(String keyID) { + return format(bundle.getString("prospero.certificate.remove.removing_key.header"), keyID); + } + + default String certificateRemovePrompt() { + return bundle.getString("prospero.certificate.remove.removing_key.prompt"); + } + + default String certificateRemoveAbort() { + return bundle.getString("prospero.certificate.remove.removing_key.aborted"); + } + + default String certificateRemoved(String keyID) { + return format(bundle.getString("prospero.certificate.remove.success"), keyID); + } + + default String certificateRevokeHeader(String keyID) { + return format(bundle.getString("prospero.certificate.revoke.header"), keyID); + } + + default String certificateRevokePrompt() { + return bundle.getString("prospero.certificate.revoke.prompt"); + } + + default String certificateRevoked() { + return bundle.getString("prospero.certificate.revoke.success"); + } } diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/CliConstants.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/CliConstants.java index 6fa3b30e4..1d9f438f6 100644 --- a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/CliConstants.java +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/CliConstants.java @@ -36,6 +36,7 @@ private Commands() { public static final String ADD = "add"; public static final String APPLY = "apply"; + public static final String CERTIFICATE = "certificate"; public static final String CHANNEL = "channel"; public static final String CLONE = "clone"; public static final String CUSTOMIZATION_INIT_CHANNEL = "init"; @@ -61,6 +62,7 @@ private Commands() { public static final String ACCEPT_AGREEMENTS = "--accept-license-agreements"; public static final String ARG_PATH = "--path"; public static final String CANDIDATE_DIR = "--candidate-dir"; + public static final String CERTIFICATE_FILE = "--certificate-file"; public static final String CHANNEL = "--channel"; public static final String CHANNEL_NAME = "--channel-name"; public static final String CHANNELS = "--channels"; @@ -75,8 +77,10 @@ private Commands() { public static final String DIR = "--dir"; public static final String FEATURE_PACK_REFERENCE = ""; public static final String FPL = "--fpl"; + public static final String GPG_CHECK = "--gpg-check"; public static final String H = "-h"; public static final String HELP = "--help"; + public static final String KEY_ID= "--key-id"; public static final String LAYERS = "--layers"; public static final String LIST_PROFILES = "--list-profiles"; public static final String LOCAL_CACHE = "--local-cache"; @@ -90,6 +94,7 @@ private Commands() { public static final String REPO_URL = ""; public static final String REPOSITORIES = "--repositories"; public static final String REVISION = "--revision"; + public static final String REVOKE_CERTIFICATE = "--revoke-certificate"; public static final String SELF = "--self"; public static final String SHADE_REPOSITORIES = "--shade-repositories"; public static final String STABILITY_LEVEL = "--stability-level"; diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificateAddCommand.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificateAddCommand.java new file mode 100644 index 000000000..64e64351e --- /dev/null +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificateAddCommand.java @@ -0,0 +1,80 @@ +/* + * 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.prospero.cli.commands.certificate; + +import org.wildfly.prospero.signatures.PGPPublicKeyInfo; +import org.wildfly.prospero.actions.CertificateAction; +import org.wildfly.prospero.signatures.PGPPublicKey; +import org.wildfly.prospero.cli.ActionFactory; +import org.wildfly.prospero.cli.CliConsole; +import org.wildfly.prospero.cli.CliMessages; +import org.wildfly.prospero.cli.ReturnCodes; +import org.wildfly.prospero.cli.commands.AbstractCommand; +import org.wildfly.prospero.cli.commands.CliConstants; +import picocli.CommandLine; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +@CommandLine.Command(name = CliConstants.Commands.ADD) +public class CertificateAddCommand extends AbstractCommand { + + @CommandLine.Option(names = CliConstants.DIR) + private Optional installationDir; + + @CommandLine.Option(names = CliConstants.CERTIFICATE_FILE, required = true) + private Path certificateFile; + + @CommandLine.Option(names = { CliConstants.Y, CliConstants.YES}) + private boolean forceAccept; + + public CertificateAddCommand(CliConsole console, ActionFactory actionFactory) { + super(console, actionFactory); + } + + @Override + public Integer call() throws Exception { + long start = System.currentTimeMillis(); + final Path serverDir = determineInstallationDirectory(installationDir); + + try (CertificateAction certificateAction = actionFactory.certificateAction(serverDir)) { + if (!Files.exists(certificateFile.toAbsolutePath()) || !Files.isReadable(certificateFile.toAbsolutePath())) { + throw CliMessages.MESSAGES.certificateNonExistingFilePath(certificateFile.toAbsolutePath()); + } + + final PGPPublicKeyInfo keyInfo = PGPPublicKeyInfo.parse(certificateFile.toAbsolutePath().toFile()); + + console.println(CliMessages.MESSAGES.certificateImportHeader()); + new KeyPrinter(console.getStdOut()).print(keyInfo); + console.emptyLine(); + + if (forceAccept || console.confirm(CliMessages.MESSAGES.certificateImportConfirmation(), + CliMessages.MESSAGES.certificateImportConfirmed(keyInfo.getKeyID().getHexKeyID()), + CliMessages.MESSAGES.certificateImportCancelled(keyInfo.getKeyID().getHexKeyID()))) { + console.emptyLine(); + + final PGPPublicKey trustCertificate = new PGPPublicKey(certificateFile.toAbsolutePath().toFile()); + certificateAction.importCertificate(trustCertificate); + + console.println(CliMessages.MESSAGES.operationCompleted((float) (System.currentTimeMillis() - start) /1000)); + } + } + + return ReturnCodes.SUCCESS; + } +} diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificateListCommand.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificateListCommand.java new file mode 100644 index 000000000..a2d4b76d3 --- /dev/null +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificateListCommand.java @@ -0,0 +1,64 @@ +/* + * 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.prospero.cli.commands.certificate; + +import org.wildfly.prospero.signatures.PGPPublicKeyInfo; +import org.wildfly.prospero.actions.CertificateAction; +import org.wildfly.prospero.cli.ActionFactory; +import org.wildfly.prospero.cli.CliConsole; +import org.wildfly.prospero.cli.CliMessages; +import org.wildfly.prospero.cli.ReturnCodes; +import org.wildfly.prospero.cli.commands.AbstractCommand; +import org.wildfly.prospero.cli.commands.CliConstants; +import picocli.CommandLine; + +import java.nio.file.Path; +import java.util.Collection; +import java.util.Optional; + +@CommandLine.Command(name=CliConstants.Commands.LIST) +public class CertificateListCommand extends AbstractCommand { + + @CommandLine.Option(names = CliConstants.DIR) + private Optional installationDir; + + public CertificateListCommand(CliConsole console, ActionFactory actionFactory) { + super(console, actionFactory); + } + + @Override + public Integer call() throws Exception { + final Path serverDir = determineInstallationDirectory(installationDir); + + try (CertificateAction certificateAction = actionFactory.certificateAction(serverDir)) { + final Collection keys = certificateAction.listCertificates(); + if (keys.isEmpty()) { + console.println(CliMessages.MESSAGES.noPublicKeysHeader()); + } else { + console.println(CliMessages.MESSAGES.publicKeysListHeader()); + console.emptyLine(); + final KeyPrinter keyPrinter = new KeyPrinter(console.getStdOut()); + for (PGPPublicKeyInfo key : keys) { + console.println("-------"); + keyPrinter.print(key); + } + } + } + return ReturnCodes.SUCCESS; + } +} diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificateRemoveCommand.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificateRemoveCommand.java new file mode 100644 index 000000000..1e51b757c --- /dev/null +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificateRemoveCommand.java @@ -0,0 +1,84 @@ +package org.wildfly.prospero.cli.commands.certificate; + +import org.wildfly.prospero.signatures.PGPKeyId; +import org.wildfly.prospero.signatures.PGPPublicKeyInfo; +import org.wildfly.prospero.actions.CertificateAction; +import org.wildfly.prospero.signatures.PGPRevokeSignature; +import org.wildfly.prospero.cli.ActionFactory; +import org.wildfly.prospero.cli.CliConsole; +import org.wildfly.prospero.cli.CliMessages; +import org.wildfly.prospero.cli.ReturnCodes; +import org.wildfly.prospero.cli.commands.AbstractCommand; +import org.wildfly.prospero.cli.commands.CliConstants; +import picocli.CommandLine; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +@CommandLine.Command(name = CliConstants.Commands.REMOVE) +public class CertificateRemoveCommand extends AbstractCommand { + + @CommandLine.Option(names = CliConstants.DIR) + private Optional installationDir; + + @CommandLine.ArgGroup(exclusive = true, multiplicity = "1") + private CertificateOptions certificateOptions; + + @CommandLine.Option(names = { CliConstants.Y, CliConstants.YES}) + private boolean forceAccept; + + static class CertificateOptions { + @CommandLine.Option(names = CliConstants.KEY_ID) + private String certificateName; + + @CommandLine.Option(names = CliConstants.REVOKE_CERTIFICATE) + private Path revokeCertificatePath; + } + + public CertificateRemoveCommand(CliConsole console, ActionFactory actionFactory) { + super(console, actionFactory); + } + + @Override + public Integer call() throws Exception { + final Path serverDir = determineInstallationDirectory(installationDir); + try (CertificateAction certificateAction = actionFactory.certificateAction(serverDir)) { + if (certificateOptions.certificateName != null) { + PGPPublicKeyInfo keyInfo = certificateAction.getCertificate(new PGPKeyId(certificateOptions.certificateName)); + if (keyInfo == null) { + console.error(CliMessages.MESSAGES.noSuchCertificate(certificateOptions.certificateName)); + return ReturnCodes.INVALID_ARGUMENTS; + } + console.println(CliMessages.MESSAGES.certificateRemoveHeader(certificateOptions.certificateName)); + console.emptyLine(); + new KeyPrinter(console.getStdOut()).print(keyInfo); + if (forceAccept || console.confirm(CliMessages.MESSAGES.certificateRemovePrompt(), "", + CliMessages.MESSAGES.certificateRemoveAbort())) { + certificateAction.removeCertificate(new PGPKeyId(certificateOptions.certificateName)); + console.println(CliMessages.MESSAGES.certificateRemoved(certificateOptions.certificateName)); + } + } else { + if (!Files.exists(certificateOptions.revokeCertificatePath)) { + throw CliMessages.MESSAGES.nonExistingFilePath(certificateOptions.revokeCertificatePath); + } + final PGPRevokeSignature revokeCertificate = new PGPRevokeSignature(certificateOptions.revokeCertificatePath.toFile()); + final PGPPublicKeyInfo revokedCertificate = certificateAction.getCertificate(revokeCertificate.getRevokedKeyId()); + if (revokedCertificate == null) { + console.error(CliMessages.MESSAGES.noSuchCertificate(revokeCertificate.getRevokedKeyId().getHexKeyID())); + return ReturnCodes.INVALID_ARGUMENTS; + } + console.println(CliMessages.MESSAGES.certificateRevokeHeader(revokedCertificate.getKeyID().getHexKeyID())); + console.emptyLine(); + new KeyPrinter(console.getStdOut()).print(revokedCertificate); + if (forceAccept || console.confirm(CliMessages.MESSAGES.certificateRevokePrompt(), "", + CliMessages.MESSAGES.certificateRemoveAbort())) { + certificateAction.revokeCertificate(revokeCertificate); + console.println(CliMessages.MESSAGES.certificateRevoked()); + } + } + } + + return ReturnCodes.SUCCESS; + } +} diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificatesCommand.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificatesCommand.java new file mode 100644 index 000000000..d94caf546 --- /dev/null +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificatesCommand.java @@ -0,0 +1,20 @@ +package org.wildfly.prospero.cli.commands.certificate; + +import org.wildfly.prospero.cli.ActionFactory; +import org.wildfly.prospero.cli.CliConsole; +import org.wildfly.prospero.cli.commands.AbstractParentCommand; +import org.wildfly.prospero.cli.commands.CliConstants; +import picocli.CommandLine; + +import java.util.List; + +@CommandLine.Command(name= CliConstants.Commands.CERTIFICATE) +public class CertificatesCommand extends AbstractParentCommand { + public CertificatesCommand(CliConsole console, ActionFactory actionFactory) { + super(console, actionFactory, CliConstants.Commands.CERTIFICATE, List.of( + new CertificateAddCommand(console, actionFactory), + new CertificateRemoveCommand(console, actionFactory), + new CertificateListCommand(console, actionFactory)) + ); + } +} diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/KeyPrinter.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/KeyPrinter.java new file mode 100644 index 000000000..7b5d6ab37 --- /dev/null +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/KeyPrinter.java @@ -0,0 +1,52 @@ +/* + * 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.prospero.cli.commands.certificate; + +import org.wildfly.prospero.signatures.PGPPublicKeyInfo; +import org.wildfly.prospero.cli.CliMessages; + +import java.io.PrintStream; + +class KeyPrinter { + + private final PrintStream writer; + + KeyPrinter(PrintStream writer) { + this.writer = writer; + } + + void print(PGPPublicKeyInfo key) { + printField(CliMessages.MESSAGES.publicKeyIdLabel(), key.getKeyID().getHexKeyID()); + printField(CliMessages.MESSAGES.publicKeyFingerprintLabel(), key.getFingerprint()); + printField(CliMessages.MESSAGES.publicKeyTrustStatusLabel(), key.getStatus()); + if (!key.getIdentity().isEmpty()) { + printField(CliMessages.MESSAGES.publicKeyUserIdsLabel(), ""); + for (String userId : key.getIdentity()) { + writer.println(" * " + userId); + } + } + printField(CliMessages.MESSAGES.publicKeyCreateTimeLabel(), key.getIssueDate()); + if (key.getExpiryDate() != null) { + printField(CliMessages.MESSAGES.publicKeyExpiresTimeLabel(), key.getExpiryDate()); + } + } + + private void printField(String key, Object value) { + writer.println(key + ": " + value); + } +} diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/channel/ChannelAddCommand.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/channel/ChannelAddCommand.java index 0eca18708..815ed533e 100644 --- a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/channel/ChannelAddCommand.java +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/channel/ChannelAddCommand.java @@ -53,6 +53,9 @@ public class ChannelAddCommand extends AbstractCommand { @CommandLine.ArgGroup(exclusive = true, multiplicity = "1") private ChannelParamsGroup channelOptions; + @CommandLine.Option(names = CliConstants.GPG_CHECK, required = false) + private boolean gpgCheck; + @CommandLine.Option(names = CliConstants.DIR) private Optional directory; @@ -86,7 +89,13 @@ public Integer call() throws Exception { final ChannelManifestCoordinate manifest = ArtifactUtils.manifestCoordFromString(channelOptions.channelGroup.manifestLocation); try (TemporaryFilesManager temporaryFiles = TemporaryFilesManager.getInstance()) { final List repositories = RepositoryUtils.unzipArchives(RepositoryDefinition.from(channelOptions.channelGroup.repositoryDefs), temporaryFiles); - channel = new Channel(channelName, null, null, repositories, manifest, null, null); + final Channel.Builder builder = new Channel.Builder() + .setName(channelName) + .setManifestCoordinate(manifest) + .setGpgCheck(gpgCheck); + + repositories.forEach(r -> builder.addRepository(r.getId(), r.getUrl())); + channel = builder.build(); } } diff --git a/prospero-cli/src/main/resources/UsageMessages.properties b/prospero-cli/src/main/resources/UsageMessages.properties index 385c6772a..873ae59fa 100644 --- a/prospero-cli/src/main/resources/UsageMessages.properties +++ b/prospero-cli/src/main/resources/UsageMessages.properties @@ -193,11 +193,19 @@ config-stability-level.0 = Select the minimal stability of configurations provis config-stability-level.1 = Valid options are ${COMPLETION-CANDIDATES}. package-stability-level.0 = Select the minimal stability of provisioned packages (e.g. JBoss Modules modules) in the server. Cannot be used together with @|bold --stability-level|@. package-stability-level.1 = Valid options are ${COMPLETION-CANDIDATES}. +certificate-file = Path to the file containing armored public key GPG certificate. +key-id = The key ID of the public key to be removed. The key needs to be in a hexadecimal form. +revoke-certificate = Path to the file containing armored revocation certificate of a public key. +gpg-check = Require all artifacts from this channel to be GPG verified. ${prospero.dist.name}.update.prepare.candidate-dir = Target directory where the candidate server will be provisioned. The existing server is not updated. ${prospero.dist.name}.update.subscribe.product = Specify the product name. This must be a known feature pack supported by ${prospero.dist.name}. ${prospero.dist.name}.update.subscribe.version = Specify the version of the product. +${prospero.dist.name}.certificate.usage.header = Manages the public keys used to verify the artifacts used in installation and updates of the server. +${prospero.dist.name}.certificate.add.usage.header = Adds a public key to verify artifacts with. +${prospero.dist.name}.certificate.list.usage.header = Lists the public keys the servers uses to verify artifacts. +${prospero.dist.name}.certificate.remove.usage.header = Removes or revokes a public key used to verify artifacts. # # Exit Codes # @@ -414,4 +422,26 @@ prospero.install.list.profile.subscribe.channels=Subscribed channels:\u0020 prospero.install.list.profile.featurePacks=Installed feature packs:\u0020 prospero.candidate.apply.error.rolled_back.desc=The incomplete update changes have been rolled back. Please resolve above error and try to perform update again. -prospero.candidate.apply.error.rollback_error.desc=Unable to restore the incomplete update changes. The server might have been left in a corrupted state, please check the backup of the server at %s. \ No newline at end of file +prospero.candidate.apply.error.rollback_error.desc=Unable to restore the incomplete update changes. The server might have been left in a corrupted state, please check the backup of the server at %s. + +prospero.certificate.import.header=Importing key: +prospero.certificate.import.confirm_prompt=This key will be used to verify artifacts during installation and update. Do you want to trust it? [y/N]\u0020 +prospero.certificate.import.confirmed=Importing the key %s +prospero.certificate.import.cancelled=Key %s was not imported. +prospero.certificate.import.cert_file_doesnt_exist=Unable to read the public key from %s. The file doesn't exist or is not-readable. +prospero.certificate.list.no_keys=The server currently has no artifact signing public keys. +prospero.certificate.list.header=Artifact signing public keys installed in the server: +prospero.certificate.remove.no_such_key=Public key %s is not available in the server. +prospero.certificate.remove.removing_key.header=Removing public key %s from the server trusted keys. +prospero.certificate.remove.removing_key.prompt=Remove the key? [y/N]\u0020 +prospero.certificate.remove.removing_key.aborted=Operation aborted. +prospero.certificate.remove.success=Key %s removed. +prospero.certificate.revoke.header=Public key %s is not available in the server. +prospero.certificate.revoke.prompt=Revoke the trust in the key? [y/N]\u0020 +prospero.certificate.revoke.success=Public key was marked as revoked. +prospero.certificate.keyId.label=Key ID +prospero.certificate.fingerprint.label=Fingerprint +prospero.certificate.trust_status.label=Trust status +prospero.certificate.user_ids.label=User IDs +prospero.certificate.created_time.label=Created +prospero.certificate.expires_time.label=Valid until diff --git a/prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/certificate/CertificateAddCommandTest.java b/prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/certificate/CertificateAddCommandTest.java new file mode 100644 index 000000000..664f566e9 --- /dev/null +++ b/prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/certificate/CertificateAddCommandTest.java @@ -0,0 +1,117 @@ +/* + * 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.prospero.cli.commands.certificate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; +import org.wildfly.prospero.actions.CertificateAction; +import org.wildfly.prospero.cli.AbstractConsoleTest; +import org.wildfly.prospero.cli.ActionFactory; +import org.wildfly.prospero.cli.CliMessages; +import org.wildfly.prospero.cli.ReturnCodes; +import org.wildfly.prospero.cli.commands.CliConstants; +import org.wildfly.prospero.test.CertificateUtils; +import org.wildfly.prospero.test.MetadataTestUtils; + +@RunWith(MockitoJUnitRunner.class) +public class CertificateAddCommandTest extends AbstractConsoleTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + @Mock + public ActionFactory actionFactory; + @Mock + public CertificateAction certificateAction; + private Path installationDir; + + protected ActionFactory createActionFactory() { + return actionFactory; + } + + @Before + public void setUp() throws Exception { + super.setUp(); + installationDir = tempFolder.newFolder().toPath(); + + MetadataTestUtils.createInstallationMetadata(installationDir); + MetadataTestUtils.createGalleonProvisionedState(installationDir, "org.wildfly.core:core-feature-pack"); + + when(actionFactory.certificateAction(eq(installationDir))) + .thenReturn(certificateAction); + } + + @Test + public void currentDirNotValidInstallation() { + int exitCode = commandLine.execute(CliConstants.Commands.CERTIFICATE, CliConstants.Commands.ADD, + CliConstants.CERTIFICATE_FILE, "afile"); + + Assert.assertEquals(ReturnCodes.INVALID_ARGUMENTS, exitCode); + assertTrue(getErrorOutput().contains(CliMessages.MESSAGES.invalidInstallationDir(Paths.get(".").toAbsolutePath().toAbsolutePath()) + .getMessage())); + } + + @Test + public void certificateFileArgumentIsRequired() throws Exception { + int exitCode = commandLine.execute(CliConstants.Commands.CERTIFICATE, CliConstants.Commands.ADD); + + Assert.assertEquals(ReturnCodes.INVALID_ARGUMENTS, exitCode); + assertThat(getErrorOutput()) + .contains("Missing required option:", CliConstants.CERTIFICATE_FILE); + } + + @Test + public void certificateFileHasToExist() throws Exception { + int exitCode = commandLine.execute(CliConstants.Commands.CERTIFICATE, CliConstants.Commands.ADD, + CliConstants.DIR, installationDir.toString(), + CliConstants.CERTIFICATE_FILE, "idontexist"); + + Assert.assertEquals(ReturnCodes.INVALID_ARGUMENTS, exitCode); + assertThat(getErrorOutput()) + .contains(CliMessages.MESSAGES.certificateNonExistingFilePath(Path.of("idontexist").toAbsolutePath()).getMessage()); + } + + @Test + public void callCertificateAction() throws Exception { + final PGPSecretKeyRing pgpSecretKeys = CertificateUtils.generatePrivateKey(); + final File publicKey = CertificateUtils.exportPublicCertificate(pgpSecretKeys, tempFolder.newFile("public.crt")); + int exitCode = commandLine.execute(CliConstants.Commands.CERTIFICATE, CliConstants.Commands.ADD, + CliConstants.DIR, installationDir.toString(), + CliConstants.CERTIFICATE_FILE, publicKey.toString()); + + Assert.assertEquals(ReturnCodes.SUCCESS, exitCode); + Mockito.verify(certificateAction).importCertificate(any()); + } +} \ No newline at end of file diff --git a/prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/certificate/CertificateListCommandTest.java b/prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/certificate/CertificateListCommandTest.java new file mode 100644 index 000000000..37681261f --- /dev/null +++ b/prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/certificate/CertificateListCommandTest.java @@ -0,0 +1,111 @@ +/* + * 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.prospero.cli.commands.certificate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.wildfly.prospero.signatures.PGPKeyId; +import org.wildfly.prospero.signatures.PGPPublicKeyInfo; +import org.wildfly.prospero.actions.CertificateAction; +import org.wildfly.prospero.cli.AbstractConsoleTest; +import org.wildfly.prospero.cli.ActionFactory; +import org.wildfly.prospero.cli.CliMessages; +import org.wildfly.prospero.cli.ReturnCodes; +import org.wildfly.prospero.cli.commands.CliConstants; +import org.wildfly.prospero.test.MetadataTestUtils; + +@RunWith(MockitoJUnitRunner.class) +public class CertificateListCommandTest extends AbstractConsoleTest { + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + @Mock + public ActionFactory actionFactory; + @Mock + public CertificateAction certificateAction; + + private Path installationDir; + + protected ActionFactory createActionFactory() { + return actionFactory; + } + + @Before + public void setUp() throws Exception { + super.setUp(); + installationDir = tempFolder.newFolder().toPath(); + + MetadataTestUtils.createInstallationMetadata(installationDir); + MetadataTestUtils.createGalleonProvisionedState(installationDir, "org.wildfly.core:core-feature-pack"); + + when(actionFactory.certificateAction(eq(installationDir))) + .thenReturn(certificateAction); + } + + @Test + public void currentDirNotValidInstallation() { + int exitCode = commandLine.execute(CliConstants.Commands.CERTIFICATE, CliConstants.Commands.LIST); + + assertEquals(ReturnCodes.INVALID_ARGUMENTS, exitCode); + assertThat(getErrorOutput()).contains(CliMessages.MESSAGES.invalidInstallationDir(Paths.get(".").toAbsolutePath().toAbsolutePath()) + .getMessage()); + } + + @Test + public void printInformationIfNoCertsAvailable() throws Exception { + when(certificateAction.listCertificates()) + .thenReturn(Collections.emptyList()); + + int exitCode = commandLine.execute(CliConstants.Commands.CERTIFICATE, CliConstants.Commands.LIST, + CliConstants.DIR, installationDir.toString()); + + assertEquals(ReturnCodes.SUCCESS, exitCode); + assertThat(getStandardOutput()) + .contains(CliMessages.MESSAGES.noPublicKeysHeader()); + } + + @Test + public void printKeysIfCertsPresent() throws Exception { + when(certificateAction.listCertificates()) + .thenReturn(List.of( + new PGPPublicKeyInfo(new PGPKeyId("A"), PGPPublicKeyInfo.Status.TRUSTED, "", Collections.emptyList(), null, null), + new PGPPublicKeyInfo(new PGPKeyId("B"), PGPPublicKeyInfo.Status.TRUSTED, "", Collections.emptyList(), null, null) + )); + + int exitCode = commandLine.execute(CliConstants.Commands.CERTIFICATE, CliConstants.Commands.LIST, + CliConstants.DIR, installationDir.toString()); + + assertEquals(ReturnCodes.SUCCESS, exitCode); + assertThat(getStandardOutput()) + .contains(CliMessages.MESSAGES.publicKeysListHeader(), "Key ID: A", "Key ID: B"); + } +} \ No newline at end of file diff --git a/prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/certificate/CertificateRemoveCommandTest.java b/prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/certificate/CertificateRemoveCommandTest.java new file mode 100644 index 000000000..52fe36898 --- /dev/null +++ b/prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/certificate/CertificateRemoveCommandTest.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.prospero.cli.commands.certificate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; + +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.wildfly.prospero.signatures.PGPKeyId; +import org.wildfly.prospero.signatures.PGPPublicKeyInfo; +import org.wildfly.prospero.ProsperoLogger; +import org.wildfly.prospero.actions.CertificateAction; +import org.wildfly.prospero.cli.AbstractConsoleTest; +import org.wildfly.prospero.cli.ActionFactory; +import org.wildfly.prospero.cli.CliMessages; +import org.wildfly.prospero.cli.ReturnCodes; +import org.wildfly.prospero.cli.commands.CliConstants; +import org.wildfly.prospero.test.CertificateUtils; +import org.wildfly.prospero.test.MetadataTestUtils; + +@RunWith(MockitoJUnitRunner.class) +public class CertificateRemoveCommandTest extends AbstractConsoleTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + @Mock + public ActionFactory actionFactory; + @Mock + public CertificateAction certificateAction; + private Path installationDir; + + protected ActionFactory createActionFactory() { + return actionFactory; + } + + @Before + public void setUp() throws Exception { + super.setUp(); + installationDir = tempFolder.newFolder().toPath(); + + MetadataTestUtils.createInstallationMetadata(installationDir); + MetadataTestUtils.createGalleonProvisionedState(installationDir, "org.wildfly.core:core-feature-pack"); + + when(actionFactory.certificateAction(eq(installationDir))) + .thenReturn(certificateAction); + } + + @Test + public void currentDirNotValidInstallation() { + int exitCode = commandLine.execute(CliConstants.Commands.CERTIFICATE, CliConstants.Commands.REMOVE, + CliConstants.KEY_ID, "idontexist"); + + Assert.assertEquals(ReturnCodes.INVALID_ARGUMENTS, exitCode); + assertTrue(getErrorOutput().contains(CliMessages.MESSAGES.invalidInstallationDir(Paths.get(".").toAbsolutePath().toAbsolutePath()) + .getMessage())); + } + + @Test + public void keyIdOrRevokeCertificateIsRequired() throws Exception { + int exitCode = commandLine.execute(CliConstants.Commands.CERTIFICATE, CliConstants.Commands.REMOVE, + CliConstants.DIR, installationDir.toString()); + + Assert.assertEquals(ReturnCodes.INVALID_ARGUMENTS, exitCode); + assertThat(getErrorOutput()) + .contains("Missing required argument", CliConstants.KEY_ID, CliConstants.REVOKE_CERTIFICATE); + } + + @Test + public void noCertificateWithKeyId() throws Exception { + when(certificateAction.getCertificate(new PGPKeyId("idontexist"))).thenReturn(null); + + int exitCode = commandLine.execute(CliConstants.Commands.CERTIFICATE, CliConstants.Commands.REMOVE, + CliConstants.DIR, installationDir.toString(), + CliConstants.KEY_ID, "idontexist"); + + Assert.assertEquals(ReturnCodes.INVALID_ARGUMENTS, exitCode); + assertThat(getErrorOutput()) + .contains(CliMessages.MESSAGES.noSuchCertificate("idontexist")); + } + + @Test + public void revokeCertificateIsNonExisting() throws Exception { + int exitCode = commandLine.execute(CliConstants.Commands.CERTIFICATE, CliConstants.Commands.REMOVE, + CliConstants.DIR, installationDir.toString(), + CliConstants.REVOKE_CERTIFICATE, "idontexist"); + + Assert.assertEquals(ReturnCodes.INVALID_ARGUMENTS, exitCode); + assertThat(getErrorOutput()) + .contains(CliMessages.MESSAGES.nonExistingFilePath(Path.of("idontexist")).getMessage()); + } + + @Test + public void callRemoveWithTheKeyId() throws Exception { + when(certificateAction.getCertificate(new PGPKeyId("a_key"))).thenReturn(new PGPPublicKeyInfo(new PGPKeyId("A"), PGPPublicKeyInfo.Status.TRUSTED, + "", Collections.emptyList(), null, null)); + + int exitCode = commandLine.execute(CliConstants.Commands.CERTIFICATE, CliConstants.Commands.REMOVE, + CliConstants.DIR, installationDir.toString(), + CliConstants.KEY_ID, "a_key"); + + Assert.assertEquals(ReturnCodes.SUCCESS, exitCode); + verify(certificateAction).removeCertificate(new PGPKeyId("a_key")); + } + + @Test + public void callRevokeWithCertFile() throws Exception { + final PGPSecretKeyRing pgpSecretKeys = CertificateUtils.generatePrivateKey(); + final File file = CertificateUtils.generateRevocationSignature(pgpSecretKeys, tempFolder.newFile("revoke.crt")); + when(certificateAction.getCertificate(new PGPKeyId(pgpSecretKeys.getPublicKey().getKeyID()))).thenReturn(new PGPPublicKeyInfo(new PGPKeyId("A"), PGPPublicKeyInfo.Status.TRUSTED, + "", Collections.emptyList(), null, null)); + + int exitCode = commandLine.execute(CliConstants.Commands.CERTIFICATE, CliConstants.Commands.REMOVE, + CliConstants.DIR, installationDir.toString(), + CliConstants.REVOKE_CERTIFICATE, file.getAbsolutePath()); + + Assert.assertEquals(ReturnCodes.SUCCESS, exitCode); + verify(certificateAction).revokeCertificate(any()); + } + + @Test + public void callRevokeWithCertFileNonExistingPublicKey() throws Exception { + final PGPSecretKeyRing pgpSecretKeys = CertificateUtils.generatePrivateKey(); + final File file = CertificateUtils.generateRevocationSignature(pgpSecretKeys, tempFolder.newFile("revoke.crt")); + final PGPKeyId keyID = new PGPKeyId(pgpSecretKeys.getPublicKey().getKeyID()); + when(certificateAction.getCertificate(keyID)) + .thenReturn(null); + + int exitCode = commandLine.execute(CliConstants.Commands.CERTIFICATE, CliConstants.Commands.REMOVE, + CliConstants.DIR, installationDir.toString(), + CliConstants.REVOKE_CERTIFICATE, file.getAbsolutePath()); + + Assert.assertEquals(ReturnCodes.INVALID_ARGUMENTS, exitCode); + assertThat(getErrorOutput()) + .contains(CliMessages.MESSAGES.noSuchCertificate(keyID.getHexKeyID())); + } + + @Test + public void invalidRevokeCertificate() throws Exception { + final File file = Files.writeString(tempFolder.newFile("revoke.crt").toPath(), "I'm not a certificate").toFile(); + + int exitCode = commandLine.execute(CliConstants.Commands.CERTIFICATE, CliConstants.Commands.REMOVE, + CliConstants.DIR, installationDir.toString(), + CliConstants.REVOKE_CERTIFICATE, file.getAbsolutePath()); + + Assert.assertEquals(ReturnCodes.PROCESSING_ERROR, exitCode); + assertThat(getErrorOutput()) + .contains(ProsperoLogger.ROOT_LOGGER.invalidCertificate(file.getAbsolutePath(), "", null).getMessage()); + } + + + + +} \ No newline at end of file diff --git a/prospero-cli/src/test/java/org/wildfly/prospero/test/CertificateUtils.java b/prospero-cli/src/test/java/org/wildfly/prospero/test/CertificateUtils.java new file mode 100644 index 000000000..274c632b4 --- /dev/null +++ b/prospero-cli/src/test/java/org/wildfly/prospero/test/CertificateUtils.java @@ -0,0 +1,220 @@ +/* + * 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.prospero.test; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.security.InvalidAlgorithmParameterException; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Date; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; +import java.util.stream.Collectors; + +import org.assertj.core.api.Condition; +import org.bouncycastle.bcpg.ArmoredOutputStream; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.util.io.Streams; +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.spi.SignatureResult; +import org.wildfly.channel.spi.SignatureValidator; +import org.wildfly.prospero.signatures.PGPKeyId; + +public class CertificateUtils { + + public static PGPSecretKeyRing generatePrivateKey() throws InvalidAlgorithmParameterException, NoSuchAlgorithmException, PGPException { + return PGPainless.generateKeyRing().simpleRsaKeyRing("Test ", RsaLength._4096); + } + + public static PGPSecretKeyRing generateExpiredPrivateKey() throws InvalidAlgorithmParameterException, NoSuchAlgorithmException, PGPException { + // for some reason sometimes it generates a non-expiring cert + PGPSecretKeyRing expiredPrivateKey = null; + int regenCounter = 0; + do { + if (regenCounter++ > 10) { + throw new RuntimeException("Unable to generate expired certificate"); + } + try { + expiredPrivateKey = doGenereteExpiredPrivateKey(); + } catch (IllegalArgumentException e) { + // sometimes the exception is thrown when setting the expiry date, ignore it and retry + e.printStackTrace(); + } + } while (expiredPrivateKey == null || expiredPrivateKey.getPublicKey().getValidSeconds() <= 0); + return expiredPrivateKey; + } + + private static PGPSecretKeyRing doGenereteExpiredPrivateKey() throws NoSuchAlgorithmException, PGPException, InvalidAlgorithmParameterException { + return 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() + 2_000)) + .build(); + } + + public static void assertKeystoreContainsOnly(Path keystoreFile, long... expectedKeyIds) throws IOException { + final HashSet actualKeyIds = getKeyIds(keystoreFile); + + assertThat(actualKeyIds) + .containsExactlyInAnyOrderElementsOf(Arrays.stream(expectedKeyIds).boxed() + .map(PGPKeyId::new) + .collect(Collectors.toList())); + } + + public static void assertKeystoreContains(Path keystoreFile, long keyID) throws IOException { + final HashSet keyIds = getKeyIds(keystoreFile); + + assertThat(keyIds).contains(new PGPKeyId(keyID)); + } + + public static void assertKeystoreIsEmpty(Path keystoreFile) throws IOException { + final HashSet keyIds = getKeyIds(keystoreFile); + + assertThat(keyIds).isEmpty(); + } + + private static HashSet getKeyIds(Path keystoreFile) throws IOException { + final PGPPublicKeyRingCollection pgpPublicKeys = PGPainless.readKeyRing().publicKeyRingCollection(new FileInputStream(keystoreFile.toFile())); + + final HashSet keyIds = new HashSet<>(); + final Iterator keyRings = pgpPublicKeys.getKeyRings(); + while (keyRings.hasNext()) { + final Iterator publicKeys = keyRings.next().getPublicKeys(); + while (publicKeys.hasNext()) { + keyIds.add(new PGPKeyId(publicKeys.next().getKeyID())); + } + } + return keyIds; + } + + public static File exportPublicCertificate(PGPSecretKeyRing keyRing, File publicCertFile) throws IOException { + // export the public certificate + try (ArmoredOutputStream outStream = new ArmoredOutputStream(new FileOutputStream(publicCertFile))) { + keyRing.getPublicKey().encode(outStream); + } + return publicCertFile; + } + + public static File generateRevocationSignature(PGPSecretKeyRing pgpValidKeys, File publicCertFile) throws PGPException, IOException { + final PGPSecretKeyRing revokedKeyRing = PGPainless.modifyKeyRing(pgpValidKeys) + .revoke(new UnprotectedKeysProtector(), + RevocationAttributes + .createKeyRevocation() + .withReason(RevocationAttributes.Reason.KEY_COMPROMISED) + .withDescription("The key is revoked")) + .done(); + final Iterator signatures = revokedKeyRing.getPublicKey().getSignatures(); + while (signatures.hasNext()) { + final PGPSignature signature = signatures.next(); + if (signature.getSignatureType() == PGPSignature.KEY_REVOCATION) { + try (ArmoredOutputStream outStream = new ArmoredOutputStream(new FileOutputStream(publicCertFile))) { + signature.encode(outStream); + } + } + } + + return publicCertFile; + } + + public static File generateRevokedKey(PGPSecretKeyRing pgpValidKeys, File publicCertFile) throws PGPException, IOException { + final PGPSecretKeyRing revokedKeyRing = PGPainless.modifyKeyRing(pgpValidKeys) + .revoke(new UnprotectedKeysProtector(), + RevocationAttributes + .createKeyRevocation() + .withReason(RevocationAttributes.Reason.KEY_COMPROMISED) + .withDescription("The key is revoked")) + .done(); + return exportPublicCertificate(revokedKeyRing, publicCertFile); + } + + public static File signFile(Path file, File signatureFile, PGPSecretKeyRing pgpSecretKeys) throws PGPException, IOException { + final SigningOptions signOptions = SigningOptions.get() + .addDetachedSignature(new UnprotectedKeysProtector(), pgpSecretKeys); + + final EncryptionStream encryptionStream = PGPainless.encryptAndOrSign() + .onOutputStream(new FileOutputStream(signatureFile)) + .withOptions(ProducerOptions.sign(signOptions)); + + Streams.pipeAll(new FileInputStream(file.toFile()), 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; + } + + public static Condition result(SignatureValidator.SignatureException exception, SignatureResult.Result expectedResult) { + return new Condition<>(e -> exception.getSignatureResult().getResult() == expectedResult, + "Expected exception state %s but was %s", expectedResult, exception.getSignatureResult().getResult()); + } + + public static boolean isExpired(PGPPublicKey publicKey) { + if (publicKey.getValidSeconds() == 0) { + System.out.println(publicKey.getValidSeconds()); + return false; + } else { + final Instant expiry = Instant.from(publicKey.getCreationTime().toInstant().plus(publicKey.getValidSeconds(), ChronoUnit.SECONDS)); + return expiry.isBefore(Instant.now()); + } + } + + public static void waitUntilExpires(PGPSecretKeyRing expiredKeys) throws InterruptedException { + final long start = System.currentTimeMillis(); + final long maxWait = 60_000; + while (!CertificateUtils.isExpired(expiredKeys.getPublicKey())) { + if (System.currentTimeMillis() > start + maxWait) { + throw new RuntimeException(String.format("The certificate %s has not expired in %d seconds", + new PGPKeyId(expiredKeys.getPublicKey().getKeyID()).getHexKeyID(), maxWait)); + } + //noinspection BusyWait + Thread.sleep(100); + } + } +} diff --git a/prospero-common/src/main/java/org/wildfly/prospero/actions/CertificateAction.java b/prospero-common/src/main/java/org/wildfly/prospero/actions/CertificateAction.java new file mode 100644 index 000000000..92c92f64e --- /dev/null +++ b/prospero-common/src/main/java/org/wildfly/prospero/actions/CertificateAction.java @@ -0,0 +1,133 @@ +/* + * 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.prospero.actions; + +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPSignature; +import org.wildfly.prospero.signatures.InvalidCertificateException; +import org.wildfly.prospero.signatures.KeystoreWriteException; +import org.wildfly.prospero.signatures.DuplicatedCertificateException; +import org.wildfly.prospero.signatures.PGPKeyId; +import org.wildfly.prospero.signatures.PGPPublicKeyInfo; +import org.wildfly.prospero.ProsperoLogger; +import org.wildfly.prospero.signatures.PGPRevokeSignature; +import org.wildfly.prospero.signatures.PGPPublicKey; +import org.wildfly.prospero.api.exceptions.MetadataException; +import org.wildfly.prospero.signatures.NoSuchCertificateException; +import org.wildfly.prospero.metadata.ProsperoMetadataUtils; +import org.wildfly.prospero.signatures.KeystoreManager; +import org.wildfly.prospero.signatures.PGPLocalKeystore; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; + +/** + * Operations to manage the trusted certificates used to verify server artifacts + */ +public class CertificateAction implements AutoCloseable { + private final PGPLocalKeystore localGpgKeystore; + + public CertificateAction(Path installationDir) throws MetadataException { + final Path keyringPath = installationDir.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg"); + localGpgKeystore = KeystoreManager.keystoreFor(keyringPath); + } + + /** + * Adds the {@code trustCertificate} to the stored trusted certificates used to verify components. + * + * @param trustCertificate - the certificate to import. + * @throws InvalidCertificateException - if the {@code trustCertificate} cannot be parsed as a public key + * @throws DuplicatedCertificateException - if a certificate with the same ID as {@code trustCertificate} is already imported + * @throws KeystoreWriteException - if unable to persist changes to the keystore + */ + public void importCertificate(PGPPublicKey trustCertificate) + throws InvalidCertificateException, DuplicatedCertificateException, KeystoreWriteException { + + final PGPPublicKeyRing pgpPublicKeyRing = trustCertificate.getPublicKeyRing(); + + localGpgKeystore.importCertificate(asList(pgpPublicKeyRing.getPublicKeys())); + } + + /** + * Removes a public key with ID {@code keyID} from the keystore + * + * @param keyID - the HEX form of the public key ID + * @throws NoSuchCertificateException - if the keystore does not contain a matching public key + * @throws KeystoreWriteException - if unable to persist changes to the keystore + */ + public void removeCertificate(PGPKeyId keyID) throws NoSuchCertificateException, KeystoreWriteException { + if (localGpgKeystore.getCertificate(keyID) == null) { + throw ProsperoLogger.ROOT_LOGGER.noSuchCertificate(keyID.getHexKeyID()); + } + localGpgKeystore.removeCertificate(keyID); + } + + /** + * Imports a revocation signature for one of the public keys. This public key will no longer be trusted to verify artifacts. + * + * @param revokeCertificate - the revocation signature for one of the imported public keys + * + * @throws NoSuchCertificateException - if the public key for the revocation signature has not been imported yet + * @throws KeystoreWriteException - if unable to persist changes to the keystore + */ + public void revokeCertificate(PGPRevokeSignature revokeCertificate) + throws NoSuchCertificateException, KeystoreWriteException { + final PGPSignature pgpSignature = revokeCertificate.getPgpSignature(); + localGpgKeystore.revokeCertificate(pgpSignature); + } + + /** + * List all public keys imported in the server. + * + * @return a Collection of {@code KeyInfo}s + */ + public Collection listCertificates() { + return localGpgKeystore.listCertificates(); + } + + /** + * Retrieves a public key with ID of {@code keyID} from the server's keystore. + * + * @param keyID - a HEX encoded ID of the public key + * @return the {@code KeyInfo} of the public key, + * or null if no matching public key was found + */ + public PGPPublicKeyInfo getCertificate(PGPKeyId keyID) { + final Optional pgpPublicKey = localGpgKeystore.listCertificates().stream() + .filter(k->k.getKeyID().equals(keyID)) + .findFirst(); + return pgpPublicKey.orElse(null); + } + + private List asList(Iterator publicKeys) { + final ArrayList res = new ArrayList<>(); + while (publicKeys.hasNext()) { + res.add(publicKeys.next()); + } + return res; + } + + @Override + public void close() throws Exception { + localGpgKeystore.close(); + } +} From 286800130678b6950b0ec2146de44400466f7b61 Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Thu, 12 Sep 2024 14:52:10 +0100 Subject: [PATCH 05/11] Add Console operation to confirm signature --- .../wildfly/prospero/it/AcceptingConsole.java | 5 +++++ .../org/wildfly/prospero/cli/CliConsole.java | 22 +++++++++++++++++++ .../org/wildfly/prospero/api/Console.java | 2 ++ .../PromoteArtifactBundleActionTest.java | 5 +++++ 4 files changed, 34 insertions(+) diff --git a/integration-tests/src/test/java/org/wildfly/prospero/it/AcceptingConsole.java b/integration-tests/src/test/java/org/wildfly/prospero/it/AcceptingConsole.java index f01451fbb..02d25ea7d 100644 --- a/integration-tests/src/test/java/org/wildfly/prospero/it/AcceptingConsole.java +++ b/integration-tests/src/test/java/org/wildfly/prospero/it/AcceptingConsole.java @@ -52,4 +52,9 @@ public void buildUpdatesComplete() { public boolean confirmBuildUpdates() { return true; } + + @Override + public boolean acceptPublicKey(String key) { + return true; + } } diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliConsole.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliConsole.java index 19b1e68f3..79760dbbd 100644 --- a/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliConsole.java +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliConsole.java @@ -17,6 +17,7 @@ package org.wildfly.prospero.cli; +import java.io.IOException; import java.io.InputStream; import java.io.PrintStream; import java.util.HashMap; @@ -268,4 +269,25 @@ public void printf(String text, String... args) { } } + @Override + public boolean acceptPublicKey(String key) { + System.out.println(); + System.out.println("Installing an artifact signed with untrusted key: "); + System.out.println(" " + key); + System.out.println("Do you want to trust this key y/N "); + try { + while (true) { + final char read = (char) System.in.read(); + if (read == 'y' || read == 'Y') { + return true; + } else if (read == 'n' || read == 'N') { + return false; + } else { + System.out.println("Do you want to trust this key y/N "); + } + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } } diff --git a/prospero-common/src/main/java/org/wildfly/prospero/api/Console.java b/prospero-common/src/main/java/org/wildfly/prospero/api/Console.java index 627911f98..96d0b1fd3 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/api/Console.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/api/Console.java @@ -33,4 +33,6 @@ public interface Console { * @param text */ void println(String text); + + boolean acceptPublicKey(String key); } diff --git a/prospero-common/src/test/java/org/wildfly/prospero/actions/PromoteArtifactBundleActionTest.java b/prospero-common/src/test/java/org/wildfly/prospero/actions/PromoteArtifactBundleActionTest.java index c7edad373..6dee4695a 100644 --- a/prospero-common/src/test/java/org/wildfly/prospero/actions/PromoteArtifactBundleActionTest.java +++ b/prospero-common/src/test/java/org/wildfly/prospero/actions/PromoteArtifactBundleActionTest.java @@ -78,5 +78,10 @@ public void progressUpdate(ProvisioningProgressEvent update) { public void println(String text) { } + + @Override + public boolean acceptPublicKey(String key) { + return false; + } } } \ No newline at end of file From 2f45fc4cf8f6169473c137c93cfef3261703e6fa Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Thu, 12 Sep 2024 16:28:41 +0100 Subject: [PATCH 06/11] Support signatures when provisioning a server --- .../it/signatures/InstallationTestCase.java | 194 ++++++++++++++++++ .../prospero/test/TestLocalRepository.java | 44 ++++ .../cli/ExecutionExceptionHandler.java | 36 +++- .../prospero/actions/ProvisioningAction.java | 47 ++++- .../api/TemporaryRepositoriesHandler.java | 14 ++ .../prospero/galleon/GalleonEnvironment.java | 65 +++++- .../galleon/TemporaryManifestSignature.java | 155 ++++++++++++++ .../signatures/ConfirmingKeystoreAdapter.java | 76 +++++++ .../api/TemporaryRepositoriesHandlerTest.java | 13 ++ .../galleon/GalleonEnvironmentTest.java | 112 ++++++++++ .../ConfirmingKeystoreAdapterTest.java | 100 +++++++++ 11 files changed, 841 insertions(+), 15 deletions(-) create mode 100644 integration-tests/src/test/java/org/wildfly/prospero/it/signatures/InstallationTestCase.java create mode 100644 prospero-common/src/main/java/org/wildfly/prospero/galleon/TemporaryManifestSignature.java create mode 100644 prospero-common/src/main/java/org/wildfly/prospero/signatures/ConfirmingKeystoreAdapter.java create mode 100644 prospero-common/src/test/java/org/wildfly/prospero/signatures/ConfirmingKeystoreAdapterTest.java diff --git a/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/InstallationTestCase.java b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/InstallationTestCase.java new file mode 100644 index 000000000..612909dfa --- /dev/null +++ b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/InstallationTestCase.java @@ -0,0 +1,194 @@ +/* + * 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.prospero.it.signatures; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.wildfly.prospero.test.CertificateUtils.result; + +import java.io.File; +import java.net.URL; +import java.nio.file.Path; +import java.util.List; + +import org.assertj.core.api.Assertions; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.eclipse.aether.artifact.DefaultArtifact; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.wildfly.channel.Channel; +import org.wildfly.channel.ChannelManifest; +import org.wildfly.channel.Stream; +import org.wildfly.channel.spi.SignatureResult; +import org.wildfly.channel.spi.SignatureValidator; +import org.wildfly.prospero.it.AcceptingConsole; +import org.wildfly.prospero.metadata.ProsperoMetadataUtils; +import org.wildfly.prospero.test.BuildProperties; +import org.wildfly.prospero.test.CertificateUtils; +import org.wildfly.prospero.test.TestInstallation; +import org.wildfly.prospero.test.TestLocalRepository; + +public class InstallationTestCase { + + // TODO: missing use case - install using custom keyring + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + private TestLocalRepository testLocalRepository; + private TestInstallation testInstallation; + private Path serverPath; + private PGPSecretKeyRing pgpValidKeys; + private File certFile; + + @Before + public void setUp() throws Exception { + testLocalRepository = new TestLocalRepository(temp.newFolder("local-repo").toPath(), + List.of(new URL("https://repo1.maven.org/maven2"))); + + prepareRequiredArtifacts(testLocalRepository); + + serverPath = temp.newFolder("server").toPath(); + testInstallation = new TestInstallation(serverPath); + + testLocalRepository.deploy(TestInstallation.fpBuilder("org.test:pack-one:1.0.0") + .addModule("commons-io", "commons-io", "2.16.1") + .build()); + pgpValidKeys = CertificateUtils.generatePrivateKey(); + certFile = CertificateUtils.exportPublicCertificate(pgpValidKeys, temp.newFile("public.crt")); + testLocalRepository.signAllArtifacts(pgpValidKeys); + } + + @Test + public void acceptCertificateDuringInstall_RecordsCertificates() throws Exception { + final Channel testChannel = new Channel.Builder() + .setName("test-channel") + .setGpgCheck(true) + .addGpgUrl(certFile.toURI().toString()) + .addRepository("local-repo", testLocalRepository.getUri().toString()) + .setManifestCoordinate("org.test", "test-channel", "1.0.0") + .build(); + + testInstallation.install("org.test:pack-one:1.0.0", List.of(testChannel)); + + testInstallation.verifyModuleJar("commons-io", "commons-io", "2.16.1"); + testInstallation.verifyInstallationMetadataPresent(); + CertificateUtils.assertKeystoreContains(serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg"), pgpValidKeys.getPublicKey().getKeyID()); + } + + @Test + public void rejectCertificate_DoesNotInstallServer() throws Exception { + final Channel testChannel = new Channel.Builder() + .setName("test-channel") + .setGpgCheck(true) + .addGpgUrl(certFile.toURI().toString()) + .addRepository("local-repo", testLocalRepository.getUri().toString()) + .setManifestCoordinate("org.test", "test-channel", "1.0.0") + .build(); + + final Exception exception = Assertions.catchException(() -> + testInstallation.install("org.test:pack-one:1.0.0", List.of(testChannel), new AcceptingConsole() { + @Override + public boolean acceptPublicKey(String key) { + return false; + } + }) + ); + + assertThat(exception) + .isInstanceOf(SignatureValidator.SignatureException.class) + .has(result((SignatureValidator.SignatureException) exception, SignatureResult.Result.NO_MATCHING_CERT)); + + assertThat(serverPath).isEmptyDirectory(); + } + + @Test + public void missingCertificate_DoesNotInstallServer() throws Exception { + testLocalRepository.removeSignature("commons-io", "commons-io", BuildProperties.getProperty("version.commons-io")); + + final Channel testChannel = new Channel.Builder() + .setName("test-channel") + .setGpgCheck(true) + .addGpgUrl(certFile.toURI().toString()) + .addRepository("local-repo", testLocalRepository.getUri().toString()) + .setManifestCoordinate("org.test", "test-channel", "1.0.0") + .build(); + + final Exception exception = Assertions.catchException(() -> + testInstallation.install("org.test:pack-one:1.0.0", List.of(testChannel)) + ); + + assertThat(exception) + .hasCauseInstanceOf(SignatureValidator.SignatureException.class) + .has(result((SignatureValidator.SignatureException) exception.getCause(), SignatureResult.Result.NO_SIGNATURE)); + + assertThat(serverPath).isEmptyDirectory(); + } + + @Test + public void installWithOfflineRepository_DoesNotRequireSignatures() throws Exception { + final String commonsIoVersion = BuildProperties.getProperty("version.commons-io"); + + TestLocalRepository testLocalRepositoryTwo = new TestLocalRepository(temp.newFolder("repo-two").toPath(), + List.of( + new URL("https://repo1.maven.org/maven2"), + testLocalRepository.getUri().toURL() + )); + + prepareRequiredArtifacts(testLocalRepositoryTwo); + testLocalRepositoryTwo.resolveAndDeploy(new DefaultArtifact("org.test", "pack-one", "zip", "1.0.0")); + + final Channel testChannel = new Channel.Builder() + .setName("test-channel") + .setGpgCheck(true) + .addGpgUrl(certFile.toURI().toString()) + .addRepository("local-repo", testLocalRepository.getUri().toString()) + .setManifestCoordinate("org.test", "test-channel", "1.0.0") + .build(); + + final AcceptingConsole rejectCert = new AcceptingConsole() { + @Override + public boolean acceptPublicKey(String key) { + return false; + } + }; + testInstallation.install("org.test:pack-one:1.0.0", List.of(testChannel), rejectCert, List.of(testLocalRepositoryTwo.getUri().toURL())); + + testInstallation.verifyModuleJar("commons-io", "commons-io", commonsIoVersion); + testInstallation.verifyInstallationMetadataPresent(); + CertificateUtils.assertKeystoreIsEmpty(serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg")); + } + + private void prepareRequiredArtifacts(TestLocalRepository localRepository) throws Exception { + final String galleonPluginsVersion = BuildProperties.getProperty("version.org.wildfly.galleon-plugins"); + final String commonsIoVersion = BuildProperties.getProperty("version.commons-io"); + + localRepository.resolveAndDeploy(new DefaultArtifact("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", "jar", galleonPluginsVersion)); + localRepository.resolveAndDeploy(new DefaultArtifact("org.wildfly.galleon-plugins", "wildfly-config-gen", "jar", galleonPluginsVersion)); + localRepository.resolveAndDeploy(new DefaultArtifact("commons-io", "commons-io", "jar", commonsIoVersion)); + + localRepository.deploy( + new DefaultArtifact("org.test", "test-channel", "manifest", "yaml","1.0.0"), + new ChannelManifest("test-manifest", null, null, List.of( + new Stream("org.wildfly.galleon-plugins", "wildfly-config-gen", galleonPluginsVersion), + new Stream("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", galleonPluginsVersion), + new Stream("commons-io", "commons-io", commonsIoVersion), + new Stream("org.test", "pack-one", "1.0.0") + ))); + } + +} diff --git a/integration-tests/src/test/java/org/wildfly/prospero/test/TestLocalRepository.java b/integration-tests/src/test/java/org/wildfly/prospero/test/TestLocalRepository.java index d5feeadd8..39f9fd403 100644 --- a/integration-tests/src/test/java/org/wildfly/prospero/test/TestLocalRepository.java +++ b/integration-tests/src/test/java/org/wildfly/prospero/test/TestLocalRepository.java @@ -20,12 +20,18 @@ import java.io.IOException; import java.net.URI; import java.net.URL; +import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; +import org.apache.commons.io.FileUtils; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPSecretKeyRing; import org.eclipse.aether.DefaultRepositorySystemSession; import org.eclipse.aether.RepositorySystem; import org.eclipse.aether.artifact.Artifact; @@ -114,6 +120,44 @@ public void resolveAndDeploy(Artifact artifact) throws ArtifactResolutionExcepti deploy(resolveUpstream(artifact)); } + /** + * signs all unsigned artifacts in the repository + * + * @param privateKey + * @throws IOException + */ + public void signAllArtifacts(PGPSecretKeyRing privateKey) throws IOException { + Files.walkFileTree(root, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + final String fileName = file.getFileName().toString(); + final Path signatureFile = file.getParent().resolve(file.getFileName().toString() + ".asc"); + if (!Files.exists(signatureFile) && (fileName.endsWith(".jar") || fileName.endsWith(".zip") || fileName.endsWith(".yaml"))) { + try { + CertificateUtils.signFile(file, signatureFile.toFile(), privateKey); + } catch (PGPException e) { + throw new RuntimeException(e); + } + } + return FileVisitResult.CONTINUE; + } + }); + } + + /** + * Removes a detached signature (if it is present) from an artifact. + * + * @param groupId + * @param artifactId + * @param version + * @throws IOException + */ + public void removeSignature(String groupId, String artifactId, String version) throws IOException { + final Path artifactDir = root.resolve(groupId.replace('.', '/')).resolve(artifactId).resolve(version); + + Files.list(artifactDir).filter(p->p.getFileName().toString().endsWith(".asc")).forEach(path -> FileUtils.deleteQuietly(path.toFile())); + } + /** * Mocks an update to the artifact with provided GAV. * diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/ExecutionExceptionHandler.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/ExecutionExceptionHandler.java index 0e7b6a371..7baea29cf 100644 --- a/prospero-cli/src/main/java/org/wildfly/prospero/cli/ExecutionExceptionHandler.java +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/ExecutionExceptionHandler.java @@ -23,6 +23,8 @@ import org.wildfly.channel.ArtifactCoordinate; import org.wildfly.channel.ChannelMetadataCoordinate; import org.wildfly.channel.Repository; +import org.wildfly.channel.spi.ArtifactIdentifier; +import org.wildfly.channel.spi.SignatureValidator; import org.wildfly.prospero.api.ArtifactUtils; import org.wildfly.prospero.api.exceptions.ApplyCandidateException; import org.wildfly.prospero.api.exceptions.ArtifactResolutionException; @@ -122,6 +124,9 @@ public int handleExecutionException(Exception ex, CommandLine commandLine, Comma } else if (ex instanceof ProvisioningException) { handleProvisioningException((ProvisioningException)ex); returnCode = ReturnCodes.PROCESSING_ERROR; + } else if (ex instanceof SignatureValidator.SignatureException) { + handleSignatureValidationException((SignatureValidator.SignatureException) ex); + returnCode = ReturnCodes.PROCESSING_ERROR; } @@ -142,8 +147,10 @@ private void handleProvisioningException(ProvisioningException ex) { console.error("\n"); final String message = ex.getMessage(); - // the error coming from Galleon is not translated, so try to figure out what went wrong and show translated message - if (message.startsWith("Failed to parse")) { + if (ex.getCause() instanceof SignatureValidator.SignatureException) { + handleSignatureValidationException((SignatureValidator.SignatureException) ex.getCause()); + } else if (message.startsWith("Failed to parse")) { + // the error coming from Galleon is not translated, so try to figure out what went wrong and show translated message String path = message.substring("Failed to parse".length()+1).trim(); console.error(CliMessages.MESSAGES.parsingError(path)); if (ex.getCause() instanceof XMLStreamException) { @@ -154,6 +161,31 @@ private void handleProvisioningException(ProvisioningException ex) { } } + private void handleSignatureValidationException(SignatureValidator.SignatureException ex) { + final ArtifactIdentifier artifact = ex.getSignatureResult().getResource(); + switch (ex.getSignatureResult().getResult()) { + case NO_SIGNATURE: + console.error(String.format("Unable to find a required signature for artifact %s", artifact.getDescription())); + break; + case NO_MATCHING_CERT: + console.error(String.format("Unable to find a trusted certificate for key ID %s used to sign %s", ex.getSignatureResult().getKeyId(), + artifact.getDescription())); + console.error("If you wish to proceed, please review your trusted certificates."); + break; + case INVALID: + console.error(String.format("The signature for artifact %s is invalid. The artifact might be corrupted or tampered with.", + artifact.getDescription())); + break; + case REVOKED: + console.error(String.format("The key used to sign the artifact %s has been revoked with a message:%n %s.", + artifact.getDescription(), ex.getSignatureResult().getMessage())); + break; + default: + console.error(CliMessages.MESSAGES.errorHeader(ex.getCause().getLocalizedMessage())); + break; + } + } + private void printMissingMetadataException(UnresolvedChannelMetadataException ex) { console.error(CliMessages.MESSAGES.errorHeader(CliMessages.MESSAGES.unableToResolveChannelMetadata())); for (ChannelMetadataCoordinate missingArtifact : ex.getMissingArtifacts()) { diff --git a/prospero-common/src/main/java/org/wildfly/prospero/actions/ProvisioningAction.java b/prospero-common/src/main/java/org/wildfly/prospero/actions/ProvisioningAction.java index 09e6b7f65..98d96d974 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/actions/ProvisioningAction.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/actions/ProvisioningAction.java @@ -17,6 +17,7 @@ package org.wildfly.prospero.actions; +import org.apache.commons.io.FileUtils; import org.apache.commons.lang3.StringUtils; import org.eclipse.aether.resolution.ArtifactResult; import org.jboss.galleon.universe.maven.MavenUniverseException; @@ -25,6 +26,7 @@ import org.wildfly.channel.ChannelManifest; import org.wildfly.channel.Repository; import org.wildfly.channel.UnresolvedMavenArtifactException; +import org.wildfly.channel.gpg.GpgSignatureValidator; import org.wildfly.prospero.ProsperoLogger; import org.wildfly.prospero.api.Console; import org.wildfly.prospero.api.InstallationMetadata; @@ -59,6 +61,9 @@ import org.wildfly.prospero.metadata.ManifestVersionResolver; import org.wildfly.prospero.metadata.ProsperoMetadataUtils; import org.wildfly.prospero.model.ProsperoConfig; +import org.wildfly.prospero.signatures.ConfirmingKeystoreAdapter; +import org.wildfly.prospero.signatures.KeystoreManager; +import org.wildfly.prospero.signatures.PGPLocalKeystore; import org.wildfly.prospero.wfchannel.MavenSessionManager; import org.jboss.galleon.ProvisioningException; import org.jboss.galleon.api.config.GalleonProvisioningConfig; @@ -71,6 +76,7 @@ public class ProvisioningAction { private final Console console; private final LicenseManager licenseManager; private final MavenOptions mvnOptions; + private Path tempKeyringPath; public ProvisioningAction(Path installDir, MavenOptions mvnOptions, Console console) throws ProvisioningException { this.installDir = InstallFolderUtils.toRealPath(installDir); @@ -119,10 +125,14 @@ public void provision(GalleonProvisioningConfig provisioningConfig, List getPendingLicenses(GalleonProvisioningConfig provisioningConfig, List channels) throws OperationException { + public List getPendingLicenses(GalleonProvisioningConfig provisioningConfig, List channels) throws OperationException, ProvisioningException { Objects.requireNonNull(provisioningConfig); Objects.requireNonNull(channels); @@ -212,7 +247,7 @@ public List getPendingLicenses(GalleonProvisioningConfig provisioningCo return getPendingLicenses(provisioningConfig, exporter); } - private List getPendingLicenses(GalleonProvisioningConfig provisioningConfig, GalleonFeaturePackAnalyzer exporter) throws OperationException { + private List getPendingLicenses(GalleonProvisioningConfig provisioningConfig, GalleonFeaturePackAnalyzer exporter) throws OperationException, ProvisioningException { try { final Set featurePacks = exporter.getFeaturePacks(installDir, provisioningConfig); return licenseManager.getLicenses(featurePacks); @@ -230,7 +265,7 @@ private List getPendingLicenses(GalleonProvisioningConfig provisioningC // org.wildfly.channel.UnresolvedMavenArtifactException throw new ArtifactResolutionException(e.getMessage(), e); } - } catch (IOException | ProvisioningException e) { + } catch (IOException e) { throw new RuntimeException(e); } } diff --git a/prospero-common/src/main/java/org/wildfly/prospero/api/TemporaryRepositoriesHandler.java b/prospero-common/src/main/java/org/wildfly/prospero/api/TemporaryRepositoriesHandler.java index 41bd540f9..99ec2a52d 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/api/TemporaryRepositoriesHandler.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/api/TemporaryRepositoriesHandler.java @@ -26,6 +26,19 @@ public class TemporaryRepositoriesHandler { + /** + * Prepares the channels in {@code originalChannels} to use temporary repositories in {@code repositories}. + * + * Resulting repositories have the following changes: + *
    + *
  • previously configured repositories replaced with {@code repositories}
  • + *
  • disabled GPG checks
  • + *
+ * + * @param originalChannels + * @param repositories + * @return + */ public static List overrideRepositories(List originalChannels, List repositories) { Objects.requireNonNull(originalChannels); Objects.requireNonNull(repositories); @@ -39,6 +52,7 @@ public static List overrideRepositories(List originalChannels, for (Channel oc : originalChannels) { final Channel c = new Channel.Builder(oc) .setRepositories(repositories) + .setGpgCheck(false) .build(); mergedChannels.add(c); } diff --git a/prospero-common/src/main/java/org/wildfly/prospero/galleon/GalleonEnvironment.java b/prospero-common/src/main/java/org/wildfly/prospero/galleon/GalleonEnvironment.java index 89bd6d22e..09d1bbda7 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/galleon/GalleonEnvironment.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/galleon/GalleonEnvironment.java @@ -32,6 +32,7 @@ import org.wildfly.channel.ChannelSession; import org.wildfly.channel.InvalidChannelMetadataException; import org.wildfly.channel.UnresolvedMavenArtifactException; +import org.wildfly.channel.gpg.GpgSignatureValidator; import org.wildfly.channel.maven.VersionResolverFactory; import org.wildfly.channel.spi.MavenVersionsResolver; import org.wildfly.prospero.ProsperoLogger; @@ -41,6 +42,10 @@ import org.wildfly.prospero.api.exceptions.UnresolvedChannelMetadataException; import org.wildfly.prospero.api.exceptions.OperationException; import org.wildfly.prospero.metadata.ManifestVersionRecord; +import org.wildfly.prospero.metadata.ProsperoMetadataUtils; +import org.wildfly.prospero.signatures.ConfirmingKeystoreAdapter; +import org.wildfly.prospero.signatures.KeystoreManager; +import org.wildfly.prospero.signatures.PGPLocalKeystore; import org.wildfly.prospero.wfchannel.MavenSessionManager; import java.io.FileNotFoundException; @@ -57,6 +62,7 @@ import java.util.Optional; import java.util.Set; import java.util.function.Consumer; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; import org.jboss.galleon.Constants; @@ -80,10 +86,14 @@ public class GalleonEnvironment implements AutoCloseable { private Path restoreManifestPath = null; private boolean resetGalleonLineEndings = true; + private PGPLocalKeystore localGpgKeystore; - private GalleonEnvironment(Builder builder) throws ProvisioningException, MetadataException, ChannelDefinitionException, UnresolvedChannelMetadataException { + private GalleonEnvironment(Builder builder) throws ProvisioningException, MetadataException, ChannelDefinitionException, + UnresolvedChannelMetadataException { Optional console = Optional.ofNullable(builder.console); Optional restoreManifest = Optional.ofNullable(builder.manifest); + + if (restoreManifest.isPresent()) { if (LOG.isDebugEnabled()) { LOG.debug("Replacing channel manifests with restore manifest"); @@ -99,15 +109,25 @@ private GalleonEnvironment(Builder builder) throws ProvisioningException, Metada substitutedChannels.add(substitutor.substitute(channel)); } + // if console is null, we're rejecting all new certs!! + + final Path sourceServerPath = builder.sourceServerPath == null? builder.installDir:builder.sourceServerPath; + + LOG.debug("Using keystore location: " + buildKeystoreLocation(builder, sourceServerPath)); + localGpgKeystore = KeystoreManager.keystoreFor(buildKeystoreLocation(builder, sourceServerPath)); + final RepositorySystem system = builder.mavenSessionManager.newRepositorySystem(); final DefaultRepositorySystemSession session = builder.mavenSessionManager.newRepositorySystemSession(system); - final Path sourceServerPath = builder.sourceServerPath == null? builder.installDir:builder.sourceServerPath; + + final GpgSignatureValidator signatureValidator = new GpgSignatureValidator(new ConfirmingKeystoreAdapter(localGpgKeystore, chooseCertificateAcceptor(console))); + MavenVersionsResolver.Factory factory; try { - factory = new CachedVersionResolverFactory(new VersionResolverFactory(system, session, null, MavenProxyHandler::addProxySettings), sourceServerPath, system, session); + factory = new CachedVersionResolverFactory(new VersionResolverFactory(system, session, + signatureValidator, MavenProxyHandler::addProxySettings), sourceServerPath, system, session); } catch (IOException e) { ProsperoLogger.ROOT_LOGGER.debug("Unable to read artifact cache, falling back to Maven resolver.", e); - factory = new VersionResolverFactory(system, session, null, MavenProxyHandler::addProxySettings); + factory = new VersionResolverFactory(system, session, signatureValidator, MavenProxyHandler::addProxySettings); } channelSession = initChannelSession(session, factory); @@ -151,9 +171,29 @@ private GalleonEnvironment(Builder builder) throws ProvisioningException, Metada provisioning.setProgressCallback(TRACK_JB_ARTIFACTS_RESOLVE, callback); } + private static Function chooseCertificateAcceptor(Optional console) { + final Function acceptor; + if (console.isPresent()) { + acceptor = console.get()::acceptPublicKey; + } else { + LOG.debug("No console available, using the keystore in read-only mode."); + acceptor = s -> false; + } + return acceptor; + } + + private static Path buildKeystoreLocation(Builder builder, Path sourceServerPath) { + // allow for overriden location + if (builder.keyringLocation == null) { + // the default keyringLocation is the source server + return sourceServerPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg"); + } else { + return builder.keyringLocation; + } + } + private static void storeOriginalChannelManifestAsResolved(Builder builder, MavenVersionsResolver.Factory factory, List mavenManifests) { - // attempt to resolve the manifests we're reverting to. Doing so will record the manifest in the ResolvedArtifactsStore try (ChannelSession tempSession = new ChannelSession(builder.channels, factory)) { for (ManifestVersionRecord.MavenManifest mavenManifest : mavenManifests) { @@ -260,6 +300,9 @@ public void close() { if (restoreManifestPath != null) { FileUtils.deleteQuietly(restoreManifestPath.toFile()); } + if (localGpgKeystore != null) { + localGpgKeystore.close(); + } provisioning.close(); } @@ -283,6 +326,7 @@ public static class Builder { private boolean artifactDirectResolve; private List restoredManifestVersions; private final boolean useDefaultCore; + private Path keyringLocation; private GalleonProvisioningConfig config; @@ -346,8 +390,15 @@ public Builder setArtifactDirectResolve(boolean artifactDirectResolve) { return this; } - public Path getSourceServerPath() { - return sourceServerPath; + /** + * override default keystore location + * + * @param keyringLocation + * @return + */ + public Builder setKeyringLocation(Path keyringLocation) { + this.keyringLocation = keyringLocation; + return this; } } } diff --git a/prospero-common/src/main/java/org/wildfly/prospero/galleon/TemporaryManifestSignature.java b/prospero-common/src/main/java/org/wildfly/prospero/galleon/TemporaryManifestSignature.java new file mode 100644 index 000000000..186c3cb52 --- /dev/null +++ b/prospero-common/src/main/java/org/wildfly/prospero/galleon/TemporaryManifestSignature.java @@ -0,0 +1,155 @@ +/* + * Copyright 2023 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.prospero.galleon; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import org.apache.commons.io.FileUtils; +import org.bouncycastle.bcpg.CompressionAlgorithmTags; +import org.bouncycastle.bcpg.HashAlgorithmTags; +import org.bouncycastle.bcpg.PublicKeyAlgorithmTags; +import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags; +import org.bouncycastle.bcpg.sig.Features; +import org.bouncycastle.bcpg.sig.KeyFlags; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPKeyPair; +import org.bouncycastle.openpgp.PGPKeyRingGenerator; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.openpgp.PGPSignatureGenerator; +import org.bouncycastle.openpgp.PGPSignatureSubpacketGenerator; +import org.bouncycastle.openpgp.PGPUtil; +import org.bouncycastle.openpgp.operator.PBESecretKeyEncryptor; +import org.bouncycastle.openpgp.operator.PGPContentSignerBuilder; +import org.bouncycastle.openpgp.operator.PGPDigestCalculator; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder; +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPKeyPair; +import org.wildfly.prospero.api.exceptions.MetadataException; +import org.wildfly.prospero.api.exceptions.OperationException; +import org.wildfly.prospero.signatures.KeystoreManager; +import org.wildfly.prospero.signatures.KeystoreWriteException; +import org.wildfly.prospero.signatures.PGPKeyId; +import org.wildfly.prospero.signatures.PGPLocalKeystore; + +/** + * Generates and holds a temporary signature of a manifest used during revert. + * The manifest needs to be signed if any of the channels requires validation. + */ +class TemporaryManifestSignature implements AutoCloseable { + + private final String KEY_IDENTITY = "Installation Manager Restore Certificate";; + private static final int SIG_HASH = HashAlgorithmTags.SHA512; + private static final int[] HASH_PREFERENCES = new int[]{ + HashAlgorithmTags.SHA512, HashAlgorithmTags.SHA384, HashAlgorithmTags.SHA256, HashAlgorithmTags.SHA224 +}; + private static final int[] SYM_PREFERENCES = new int[]{ + SymmetricKeyAlgorithmTags.AES_256, SymmetricKeyAlgorithmTags.AES_192, SymmetricKeyAlgorithmTags.AES_128 +}; + private static final int[] COMP_PREFERENCES = new int[]{ + CompressionAlgorithmTags.ZLIB, CompressionAlgorithmTags.BZIP2, CompressionAlgorithmTags.ZLIB, CompressionAlgorithmTags.UNCOMPRESSED +}; + private final Path keystoreLocation; + private final PGPSecretKeyRing pgpSecretKey; + private final ArrayList signatures = new ArrayList<>(); + + public TemporaryManifestSignature(Path keystoreLocation) throws OperationException { + this.keystoreLocation = keystoreLocation; + + try (PGPLocalKeystore tmpKeystore = KeystoreManager.keystoreFor(keystoreLocation)) { + this.pgpSecretKey = generateSecretKeyRing(); + tmpKeystore.importCertificate(List.of(pgpSecretKey.getPublicKey())); + } catch (PGPException | NoSuchAlgorithmException e) { + throw new OperationException("Unable to generate temporary key: " + e.getLocalizedMessage(), e); + } + } + + public void sign(File source, File signature) throws PGPException, IOException { + signFile(source, signature); + signatures.add(signature); + } + + @Override + public void close() { + try (PGPLocalKeystore tmpKeystore = KeystoreManager.keystoreFor(keystoreLocation)) { + tmpKeystore.removeCertificate(new PGPKeyId(pgpSecretKey.getPublicKey().getKeyID())); + } catch (MetadataException | KeystoreWriteException e) { + throw new RuntimeException("Unable to remove temporary public key: " + e.getLocalizedMessage(), e); + } + + // delete generated signature files + signatures.forEach(FileUtils::deleteQuietly); + // remove all signatures + signatures.removeIf((s)->true); + + } + + private void signFile(File in, File signatureFile) throws PGPException, IOException { + final JcaPGPContentSignerBuilder contentSignerBuilder = new JcaPGPContentSignerBuilder( + pgpSecretKey.getPublicKey().getAlgorithm(), PGPUtil.SHA256); + PGPSignatureGenerator sGen = new PGPSignatureGenerator(contentSignerBuilder); + sGen.init(PGPSignature.PRIMARYKEY_BINDING, pgpSecretKey.getSecretKey().extractPrivateKey(null)); + try (FileInputStream fileInputStream = new FileInputStream(in)) { + sGen.update(fileInputStream.readAllBytes()); + } + final PGPSignature signature = sGen.generate(); + try (FileOutputStream outStream = new FileOutputStream(signatureFile)) { + signature.encode(outStream); + } + + } + + private PGPSecretKeyRing generateSecretKeyRing() + throws PGPException, NoSuchAlgorithmException { + PGPDigestCalculator sha1Calc = new JcaPGPDigestCalculatorProviderBuilder().build().get(HashAlgorithmTags.SHA1); + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + + PGPContentSignerBuilder contentSignerBuilder = new JcaPGPContentSignerBuilder(PublicKeyAlgorithmTags.RSA_GENERAL, SIG_HASH);//.setProvider("BC"); + PBESecretKeyEncryptor secretKeyEncryptor = null; + + Date now = new Date(); + + kpg.initialize(3072); + KeyPair primaryKP = kpg.generateKeyPair(); + PGPKeyPair primaryKey = new JcaPGPKeyPair(PGPPublicKey.RSA_GENERAL, primaryKP, now); + PGPSignatureSubpacketGenerator primarySubpackets = new PGPSignatureSubpacketGenerator(); + primarySubpackets.setKeyFlags(true, KeyFlags.CERTIFY_OTHER); + primarySubpackets.setPreferredHashAlgorithms(false, HASH_PREFERENCES); + primarySubpackets.setPreferredSymmetricAlgorithms(false, SYM_PREFERENCES); + primarySubpackets.setPreferredCompressionAlgorithms(false, COMP_PREFERENCES); + primarySubpackets.setFeature(false, Features.FEATURE_MODIFICATION_DETECTION); + primarySubpackets.setKeyFlags(true, KeyFlags.SIGN_DATA); + primarySubpackets.setIssuerFingerprint(false, primaryKey.getPublicKey()); + + PGPKeyRingGenerator gen = new PGPKeyRingGenerator(PGPSignature.POSITIVE_CERTIFICATION, primaryKey, KEY_IDENTITY, + sha1Calc, primarySubpackets.generate(), null, contentSignerBuilder, secretKeyEncryptor); + + return gen.generateSecretKeyRing(); + } +} diff --git a/prospero-common/src/main/java/org/wildfly/prospero/signatures/ConfirmingKeystoreAdapter.java b/prospero-common/src/main/java/org/wildfly/prospero/signatures/ConfirmingKeystoreAdapter.java new file mode 100644 index 000000000..b41ef9155 --- /dev/null +++ b/prospero-common/src/main/java/org/wildfly/prospero/signatures/ConfirmingKeystoreAdapter.java @@ -0,0 +1,76 @@ +/* + * 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.prospero.signatures; + +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +import org.bouncycastle.openpgp.PGPPublicKey; +import org.wildfly.channel.gpg.GpgKeystore; +import org.wildfly.channel.gpg.KeystoreOperationException; + +/** + * Verifies that the public key is accepted by the user before adding it. Uses a console acceptor to interact with the user. + */ +public class ConfirmingKeystoreAdapter implements GpgKeystore { + + private final PGPLocalKeystore localGpgKeystore; + private final Function acceptor; + + public ConfirmingKeystoreAdapter(PGPLocalKeystore localGpgKeystore, Function acceptor) { + Objects.requireNonNull(localGpgKeystore); + Objects.requireNonNull(acceptor); + + this.localGpgKeystore = localGpgKeystore; + this.acceptor = acceptor; + } + + @Override + public PGPPublicKey get(String keyID) { + return localGpgKeystore.getCertificate(new PGPKeyId(keyID)); + } + + @Override + public boolean add(List publicKey) throws KeystoreOperationException { + final String description = describeImportedKeys(publicKey); + if (acceptor.apply(description)) { + try { + localGpgKeystore.importCertificate(publicKey); + } catch (DuplicatedCertificateException | KeystoreWriteException e) { + throw new KeystoreOperationException(e.getMessage(), e); + } + return true; + } else { + return false; + } + } + + private static String describeImportedKeys(List pgpPublicKeys) { + final StringBuilder sb = new StringBuilder(); + for (PGPPublicKey pgpPublicKey : pgpPublicKeys) { + final Iterator userIDs = pgpPublicKey.getUserIDs(); + while (userIDs.hasNext()) { + sb.append(userIDs.next()); + } + sb.append(": ").append(org.bouncycastle.util.encoders.Hex.toHexString(pgpPublicKey.getFingerprint())); + } + return sb.toString(); + } +} diff --git a/prospero-common/src/test/java/org/wildfly/prospero/api/TemporaryRepositoriesHandlerTest.java b/prospero-common/src/test/java/org/wildfly/prospero/api/TemporaryRepositoriesHandlerTest.java index ca012c289..9d412b38a 100644 --- a/prospero-common/src/test/java/org/wildfly/prospero/api/TemporaryRepositoriesHandlerTest.java +++ b/prospero-common/src/test/java/org/wildfly/prospero/api/TemporaryRepositoriesHandlerTest.java @@ -70,6 +70,19 @@ public void addRepositoryToMultipleChannels() { assertRepositoryUrlContainsExactly(channels, "http://temp.te", "http://temp.te"); } + @Test + public void gpgCheckIsDisabled() { + final Channel channel = new Channel.Builder() + .setName("test-channel") + .setGpgCheck(true) + .build(); + + final List result = applyOverride(List.of(channel), List.of(repo("temp-0", "http://temp.te"))); + assertThat(result) + .map(Channel::isGpgCheck) + .containsExactly(false); + } + private static void assertRepositoryIdContainsExactly(List channels, String... ids) { assertThat(channels) .flatMap(Channel::getRepositories) diff --git a/prospero-common/src/test/java/org/wildfly/prospero/galleon/GalleonEnvironmentTest.java b/prospero-common/src/test/java/org/wildfly/prospero/galleon/GalleonEnvironmentTest.java index 5523fdfb5..13a1647fa 100644 --- a/prospero-common/src/test/java/org/wildfly/prospero/galleon/GalleonEnvironmentTest.java +++ b/prospero-common/src/test/java/org/wildfly/prospero/galleon/GalleonEnvironmentTest.java @@ -17,7 +17,11 @@ package org.wildfly.prospero.galleon; +import org.bouncycastle.bcpg.ArmoredOutputStream; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPublicKeyRing; import org.eclipse.aether.DefaultRepositorySystemSession; +import org.eclipse.aether.artifact.Artifact; import org.eclipse.aether.artifact.DefaultArtifact; import org.eclipse.aether.internal.impl.DefaultRepositorySystem; import org.eclipse.aether.resolution.ArtifactRequest; @@ -31,15 +35,23 @@ import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import org.mockito.stubbing.Answer; +import org.pgpainless.PGPainless; import org.wildfly.channel.Channel; import org.wildfly.channel.ChannelManifest; import org.wildfly.channel.ChannelManifestCoordinate; import org.wildfly.channel.ChannelManifestMapper; +import org.wildfly.channel.spi.SignatureResult; +import org.wildfly.channel.spi.SignatureValidator; +import org.wildfly.prospero.api.Console; +import org.wildfly.prospero.api.ProvisioningProgressEvent; import org.wildfly.prospero.api.exceptions.ChannelDefinitionException; import org.wildfly.prospero.metadata.ManifestVersionRecord; import org.wildfly.prospero.wfchannel.MavenSessionManager; import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; @@ -47,10 +59,13 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -187,4 +202,101 @@ public void restoreManifestIsUsedInChannels() throws Exception { .doesNotExist(); } + @Test + public void channelSessionDoesntAcceptNewCertsIfNoConsolePresent() throws Exception { + when(msm.newRepositorySystemSession(any())).thenReturn(session); + when(msm.newRepositorySystem()).thenReturn(system); + + final File keystore = temp.newFile("keystore.gpg"); + try (TemporaryManifestSignature temporaryManifestSignature = new TemporaryManifestSignature(keystore.toPath())) { + final Artifact manifestArtifact = mock(Artifact.class); + final Artifact signatureArtifact = mock(Artifact.class); + mockSignedArtifactResolution(manifestArtifact, signatureArtifact, temporaryManifestSignature); + + final File publicCertFile = temp.newFile("public_cert.crt"); + + exportPublicKey(keystore, publicCertFile); + + final Channel c1 = new Channel.Builder() + .setManifestCoordinate("group", "artifactOne", "1.0.0") + .setGpgCheck(true) + .addGpgUrl(publicCertFile.toURI().toString()) + .build(); + + final GalleonEnvironment.Builder envBuilder = GalleonEnvironment.builder(temp.newFolder().toPath(), List.of(c1), msm, true) + .setKeyringLocation(temp.newFile("test.crt").toPath()); + + assertThatThrownBy(() -> envBuilder.build()) + .isInstanceOf(SignatureValidator.SignatureException.class) + .matches((e) -> ((SignatureValidator.SignatureException) e).getSignatureResult().getResult() == SignatureResult.Result.NO_MATCHING_CERT); + } + } + + @Test + public void channelSessionAcceptNewCertsIfConsoleIsProvided() throws Exception { + when(msm.newRepositorySystemSession(any())).thenReturn(session); + when(msm.newRepositorySystem()).thenReturn(system); + + final File keystore = temp.newFile("keystore.gpg"); + try (TemporaryManifestSignature temporaryManifestSignature = new TemporaryManifestSignature(keystore.toPath())) { + final Artifact manifestArtifact = mock(Artifact.class); + final Artifact signatureArtifact = mock(Artifact.class); + mockSignedArtifactResolution(manifestArtifact, signatureArtifact, temporaryManifestSignature); + + final File publicCertFile = temp.newFile("public_cert.crt"); + + exportPublicKey(keystore, publicCertFile); + + final Channel c1 = new Channel.Builder() + .setManifestCoordinate("group", "artifactOne", "1.0.0") + .setGpgCheck(true) + .addGpgUrl(publicCertFile.toURI().toString()) + .build(); + + final GalleonEnvironment.Builder envBuilder = GalleonEnvironment.builder(temp.newFolder().toPath(), List.of(c1), msm, true) + .setConsole(new TestConsole()) + .setKeyringLocation(temp.newFile("test.crt").toPath()); + + try (GalleonEnvironment env = envBuilder.build()) { + assertNotNull(env); + } + } + } + + private void mockSignedArtifactResolution(Artifact manifestArtifact, Artifact signatureArtifact, TemporaryManifestSignature temporaryManifestSignature) throws IOException, PGPException, ArtifactResolutionException { + final File manifestFile = temp.newFile("test.yaml"); + final File signatureFile = temp.newFile("test.yaml.asc"); + Files.writeString(manifestFile.toPath(), ChannelManifestMapper.toYaml(new ChannelManifest("", "", "", Collections.emptyList()))); + when(manifestArtifact.getFile()).thenReturn(manifestFile); + when(signatureArtifact.getFile()).thenReturn(signatureFile); + temporaryManifestSignature.sign(manifestFile, signatureFile); + when(system.resolveArtifact(any(), any())).thenReturn( + new ArtifactResult(mock(ArtifactRequest.class)).setArtifact(manifestArtifact), + new ArtifactResult(mock(ArtifactRequest.class)).setArtifact(signatureArtifact) + ); + } + + private static void exportPublicKey(File keystore, File publicCertFile) throws IOException { + final PGPPublicKeyRing keyRing = PGPainless.readKeyRing().publicKeyRingCollection(new FileInputStream(keystore)).getKeyRings().next(); + try (ArmoredOutputStream outStream = new ArmoredOutputStream(new FileOutputStream(publicCertFile))) { + keyRing.getPublicKey().encode(outStream); + } + } + + private static class TestConsole implements Console { + @Override + public void progressUpdate(ProvisioningProgressEvent update) { + + } + + @Override + public void println(String text) { + + } + + @Override + public boolean acceptPublicKey(String key) { + return true; + } + } } \ No newline at end of file diff --git a/prospero-common/src/test/java/org/wildfly/prospero/signatures/ConfirmingKeystoreAdapterTest.java b/prospero-common/src/test/java/org/wildfly/prospero/signatures/ConfirmingKeystoreAdapterTest.java new file mode 100644 index 000000000..1115f0437 --- /dev/null +++ b/prospero-common/src/test/java/org/wildfly/prospero/signatures/ConfirmingKeystoreAdapterTest.java @@ -0,0 +1,100 @@ +/* + * 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.prospero.signatures; + + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.function.Function; + +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.util.encoders.Hex; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class ConfirmingKeystoreAdapterTest { + + @Mock + private PGPLocalKeystore wrappedKeystore; + @Mock + private Function acceptor; + private ConfirmingKeystoreAdapter confirmingKeystoreWrapper; + + @Before + public void setUp() throws Exception { + confirmingKeystoreWrapper = new ConfirmingKeystoreAdapter(wrappedKeystore, acceptor); + } + + @Test + public void getCallsWrappedKeystore() throws Exception { + final PGPPublicKey mockedKey = mockPublicKey(); + when(wrappedKeystore.getCertificate(new PGPKeyId("a_key"))).thenReturn(mockedKey); + + final PGPPublicKey res = confirmingKeystoreWrapper.get("a_key"); + + Mockito.verify(wrappedKeystore).getCertificate(new PGPKeyId("a_key")); + assertThat(res) + .isEqualTo(mockedKey); + } + + @Test + public void addCallsWrappedKeystoreIfAcceptorReturnsTrue() throws Exception { + final List mockedKeys = List.of(mockPublicKey()); + final ArgumentCaptor descCaptor = ArgumentCaptor.forClass(String.class); + + when(acceptor.apply(descCaptor.capture())).thenReturn(true); + assertTrue(confirmingKeystoreWrapper.add(mockedKeys)); + + Mockito.verify(wrappedKeystore).importCertificate(mockedKeys); + assertThat(descCaptor.getValue()) + .contains("Test User", "abcd"); + } + + @Test + public void addDoesNotCallsWrappedKeystoreIfAcceptorReturnsFalse() throws Exception { + final List mockedKeys = List.of(mockPublicKey()); + final ArgumentCaptor descCaptor = ArgumentCaptor.forClass(String.class); + + when(acceptor.apply(descCaptor.capture())).thenReturn(false); + assertFalse(confirmingKeystoreWrapper.add(mockedKeys)); + + Mockito.verify(wrappedKeystore, Mockito.never()).importCertificate(mockedKeys); + assertThat(descCaptor.getValue()) + .contains("Test User", "abcd"); + } + + /* + * Need to set some fields so that we can generate a description + */ + private static PGPPublicKey mockPublicKey() { + final PGPPublicKey key = Mockito.mock(PGPPublicKey.class); + when(key.getUserIDs()).thenReturn(List.of("Test User").iterator()); + when(key.getFingerprint()).thenReturn(Hex.decode("abcd")); + return key; + } +} \ No newline at end of file From 474ceba19b30802ace370231f55b3c24d919cc4e Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Thu, 12 Sep 2024 17:01:30 +0100 Subject: [PATCH 07/11] Support signatures when updating a server --- .../it/signatures/UpdateTestCase.java | 325 ++++++++++++++++++ .../prospero/actions/FeaturesAddAction.java | 8 +- .../actions/InstallationHistoryAction.java | 2 +- .../actions/PrepareCandidateAction.java | 23 +- .../ProsperoManifestVersionResolver.java | 6 +- .../prospero/actions/ProvisioningAction.java | 8 +- .../prospero/actions/UpdateAction.java | 2 +- .../galleon/GalleonFeaturePackAnalyzer.java | 39 +-- .../GalleonFeaturePackAnalyzerTest.java | 2 +- 9 files changed, 376 insertions(+), 39 deletions(-) create mode 100644 integration-tests/src/test/java/org/wildfly/prospero/it/signatures/UpdateTestCase.java diff --git a/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/UpdateTestCase.java b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/UpdateTestCase.java new file mode 100644 index 000000000..b6a2d5beb --- /dev/null +++ b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/UpdateTestCase.java @@ -0,0 +1,325 @@ +/* + * 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.prospero.it.signatures; + +import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable; +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.wildfly.prospero.test.CertificateUtils.result; + +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; + +import org.apache.commons.io.FileUtils; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.eclipse.aether.artifact.DefaultArtifact; +import org.eclipse.aether.deployment.DeploymentException; +import org.eclipse.aether.resolution.ArtifactResolutionException; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.wildfly.channel.Channel; +import org.wildfly.channel.ChannelManifest; +import org.wildfly.channel.ChannelManifestCoordinate; +import org.wildfly.channel.Repository; +import org.wildfly.channel.Stream; +import org.wildfly.channel.spi.SignatureResult; +import org.wildfly.channel.spi.SignatureValidator; +import org.wildfly.prospero.actions.UpdateAction; +import org.wildfly.prospero.api.MavenOptions; +import org.wildfly.prospero.it.AcceptingConsole; +import org.wildfly.prospero.it.utils.DirectoryComparator; +import org.wildfly.prospero.metadata.ProsperoMetadataUtils; +import org.wildfly.prospero.test.BuildProperties; +import org.wildfly.prospero.test.CertificateUtils; +import org.wildfly.prospero.test.TestInstallation; +import org.wildfly.prospero.test.TestLocalRepository; + +public class UpdateTestCase { + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + private TestLocalRepository testLocalRepository; + private TestInstallation testInstallation; + private Path serverPath; + private PGPSecretKeyRing pgpValidKeys; + private File certFile; + private String COMMONS_IO_UPDATED_VERSION; + + @Before + public void setUp() throws Exception { + COMMONS_IO_UPDATED_VERSION = BuildProperties.getProperty("version.commons-io") + ".SP1"; + testLocalRepository = new TestLocalRepository(temp.newFolder("local-repo").toPath(), + List.of(new URL("https://repo1.maven.org/maven2"))); + + prepareRequiredArtifacts(); + + serverPath = temp.newFolder("server").toPath(); + testInstallation = new TestInstallation(serverPath); + + testLocalRepository.deploy(TestInstallation.fpBuilder("org.test:pack-one:1.0.0") + .addModule("commons-io", "commons-io", "2.16.1") + .build()); + pgpValidKeys = CertificateUtils.generatePrivateKey(); + testLocalRepository.signAllArtifacts(pgpValidKeys); + + certFile = CertificateUtils.exportPublicCertificate(pgpValidKeys, temp.newFile("public.crt")); + final Channel testChannel = new Channel.Builder() + .setName("test-channel") + .setGpgCheck(true) + .addGpgUrl(certFile.toURI().toString()) + .addRepository("local-repo", testLocalRepository.getUri().toString()) + .setManifestCoordinate(new ChannelManifestCoordinate("org.test", "test-channel")) + .build(); + + testInstallation.install("org.test:pack-one:1.0.0", List.of(testChannel)); + } + + @Test + public void updateWithAcceptedCert_NoPrompt() throws Exception { + publishUpdate(); + + assertThat(testInstallation.update(new AcceptingConsole() { + @Override + public boolean acceptPublicKey(String key) { + return true; + } + })) + .isEmpty(); + + testInstallation.verifyModuleJar("commons-io", "commons-io", COMMONS_IO_UPDATED_VERSION); + } + + @Test + public void updateWithUnknownCert_NoChanges() throws Exception { + publishUpdate(); + + // remove a keyring so we need to accept it again + Files.delete(serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg")); + + final Path originalServer = temp.newFolder("original-server").toPath(); + FileUtils.copyDirectory(serverPath.toFile(), originalServer.toFile()); + + Throwable exception = catchThrowable(()->testInstallation.update(new AcceptingConsole() { + @Override + public boolean acceptPublicKey(String key) { + return false; + } + })); + assertThat(exception) + .isInstanceOf(SignatureValidator.SignatureException.class) + .has(result((SignatureValidator.SignatureException) exception, SignatureResult.Result.NO_MATCHING_CERT)); + + DirectoryComparator.assertNoChanges(originalServer, serverPath); + } + + @Test + public void updateAndAcceptNewCert_CertificateRecorded() throws Exception { + publishUpdate(); + + // remove a keyring so we need to accept it again + Files.delete(serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg")); + + final Path originalServer = temp.newFolder("original-server").toPath(); + FileUtils.copyDirectory(serverPath.toFile(), originalServer.toFile()); + + assertThat(testInstallation.update()) + .isEmpty(); + + testInstallation.verifyModuleJar("commons-io", "commons-io", COMMONS_IO_UPDATED_VERSION); + CertificateUtils.assertKeystoreContains(serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg"), pgpValidKeys.getPublicKey().getKeyID()); + } + + @Test + public void invalidArtifact_NoChanges() throws Exception { + publishUpdate(); + + // remove a certificate for the updated artifact + testLocalRepository.removeSignature("commons-io", "commons-io", COMMONS_IO_UPDATED_VERSION); + + final Path originalServer = temp.newFolder("original-server").toPath(); + FileUtils.copyDirectory(serverPath.toFile(), originalServer.toFile()); + + Throwable exception = catchThrowable(()->testInstallation.update()); + assertThat(exception) + .hasCauseInstanceOf(SignatureValidator.SignatureException.class) + .has(result((SignatureValidator.SignatureException) exception.getCause(), SignatureResult.Result.NO_SIGNATURE)); + + DirectoryComparator.assertNoChanges(originalServer, serverPath); + } + + @Test + public void expiredCertificate_NoChanges() throws Exception { + publishUpdate(); + + final PGPSecretKeyRing expiredPrivateKey = CertificateUtils.generateExpiredPrivateKey(); + CertificateUtils.exportPublicCertificate(expiredPrivateKey, certFile); + + // remove a certificate for the updated artifact + testLocalRepository.removeSignature("commons-io", "commons-io", COMMONS_IO_UPDATED_VERSION); + testLocalRepository.signAllArtifacts(expiredPrivateKey); // signs only missing signatures + + CertificateUtils.waitUntilExpires(expiredPrivateKey); + + final Path originalServer = temp.newFolder("original-server").toPath(); + FileUtils.copyDirectory(serverPath.toFile(), originalServer.toFile()); + + Throwable exception = catchThrowable(()->testInstallation.update()); + assertThat(exception) + .hasCauseInstanceOf(SignatureValidator.SignatureException.class) + .has(result((SignatureValidator.SignatureException) exception.getCause(), SignatureResult.Result.EXPIRED)); + + DirectoryComparator.assertNoChanges(originalServer, serverPath, Path.of(ProsperoMetadataUtils.METADATA_DIR, "keyring.gpg")); + } + + @Test + public void revokedCertificate_NoChanges() throws Exception { + publishUpdate(); + CertificateUtils.generateRevokedKey(pgpValidKeys, certFile); + + // remove a keyring so we need to accept it again + Files.delete(serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg")); + + final Path originalServer = temp.newFolder("original-server").toPath(); + FileUtils.copyDirectory(serverPath.toFile(), originalServer.toFile()); + + Throwable exception = catchThrowable(()->testInstallation.update()); + assertThat(exception) + .isInstanceOf(SignatureValidator.SignatureException.class) + .has(result((SignatureValidator.SignatureException) exception, SignatureResult.Result.REVOKED)); + + DirectoryComparator.assertNoChanges(originalServer, serverPath, Path.of(ProsperoMetadataUtils.METADATA_DIR, "keyring.gpg")); + } + + @Test + public void updateWithOfflineRepository_DoesNotRequireSignatures() throws Exception { + // remove a keyring so we there are no accepted signatures + final Path keystore = serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg"); + Files.delete(keystore); + + final String galleonPluginsVersion = BuildProperties.getProperty("version.org.wildfly.galleon-plugins"); + final String commonsIoVersion = BuildProperties.getProperty("version.commons-io"); + + TestLocalRepository testLocalRepositoryTwo = new TestLocalRepository(temp.newFolder("repo-two").toPath(), + List.of(new URL("https://repo1.maven.org/maven2"))); + testLocalRepositoryTwo.deployMockUpdate("commons-io", "commons-io", commonsIoVersion, ".SP1"); + + testLocalRepositoryTwo.deploy( + new DefaultArtifact("org.test", "test-channel", "manifest", "yaml","1.0.1"), + new ChannelManifest("test-manifest", null, null, List.of( + new Stream("org.wildfly.galleon-plugins", "wildfly-config-gen", galleonPluginsVersion), + new Stream("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", galleonPluginsVersion), + new Stream("commons-io", "commons-io", COMMONS_IO_UPDATED_VERSION), + new Stream("org.test", "pack-one", "1.0.0") + ))); + + final Path originalServer = temp.newFolder("original-server").toPath(); + FileUtils.copyDirectory(serverPath.toFile(), originalServer.toFile()); + + try (UpdateAction updateAction = new UpdateAction(serverPath, MavenOptions.OFFLINE_NO_CACHE, new AcceptingConsole() { + @Override + public boolean acceptPublicKey(String key) { + return false; + } + }, + List.of(new Repository("test-repo", testLocalRepositoryTwo.getUri().toString())))) { + updateAction.performUpdate(); + } + + testInstallation.verifyModuleJar("commons-io", "commons-io", COMMONS_IO_UPDATED_VERSION); + DirectoryComparator.assertNoChanges(originalServer, serverPath, + Path.of(ProsperoMetadataUtils.METADATA_DIR, "keyring.gpg"), + Path.of(".galleon"), + Path.of(ProsperoMetadataUtils.METADATA_DIR, ProsperoMetadataUtils.MANIFEST_FILE_NAME), + Path.of(ProsperoMetadataUtils.METADATA_DIR, ProsperoMetadataUtils.CURRENT_VERSION_FILE), + Path.of(ProsperoMetadataUtils.METADATA_DIR, ".git"), + Path.of(ProsperoMetadataUtils.METADATA_DIR, ".cache"), + Path.of("modules", "commons-io") + ); + if (Files.exists(keystore)) { + CertificateUtils.assertKeystoreIsEmpty(keystore); + } + } + + @Test + public void updateWithPartialRepository() throws Exception { + final String galleonPluginsVersion = BuildProperties.getProperty("version.org.wildfly.galleon-plugins"); + final String commonsIoVersion = BuildProperties.getProperty("version.commons-io"); + + TestLocalRepository testLocalRepositoryTwo = new TestLocalRepository(temp.newFolder("repo-two").toPath(), + List.of(new URL("https://repo1.maven.org/maven2"))); + testLocalRepositoryTwo.deployMockUpdate("commons-io", "commons-io", commonsIoVersion, ".SP1"); + + testLocalRepositoryTwo.deploy( + new DefaultArtifact("org.test", "test-channel", "manifest", "yaml","1.0.1"), + new ChannelManifest("test-manifest", null, null, List.of( + new Stream("org.wildfly.galleon-plugins", "wildfly-config-gen", galleonPluginsVersion), + new Stream("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", galleonPluginsVersion), + new Stream("commons-io", "commons-io", COMMONS_IO_UPDATED_VERSION), + new Stream("org.test", "pack-one", "1.0.0") + ))); + testLocalRepositoryTwo.signAllArtifacts(pgpValidKeys); + + try (UpdateAction updateAction = new UpdateAction(serverPath, MavenOptions.OFFLINE_NO_CACHE, new AcceptingConsole(), + List.of(new Repository("test-repo", testLocalRepositoryTwo.getUri().toString())))) { + assertThat(updateAction.performUpdate()) + .isEmpty(); + } + + testInstallation.verifyModuleJar("commons-io", "commons-io", COMMONS_IO_UPDATED_VERSION); + } + + private void publishUpdate() throws ArtifactResolutionException, DeploymentException, IOException { + final String galleonPluginsVersion = BuildProperties.getProperty("version.org.wildfly.galleon-plugins"); + final String commonsIoVersion = BuildProperties.getProperty("version.commons-io"); + + testLocalRepository.deployMockUpdate("commons-io", "commons-io", commonsIoVersion, ".SP1"); + + testLocalRepository.deploy( + new DefaultArtifact("org.test", "test-channel", "manifest", "yaml","1.0.1"), + new ChannelManifest("test-manifest", null, null, List.of( + new Stream("org.wildfly.galleon-plugins", "wildfly-config-gen", galleonPluginsVersion), + new Stream("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", galleonPluginsVersion), + new Stream("commons-io", "commons-io", COMMONS_IO_UPDATED_VERSION), + new Stream("org.test", "pack-one", "1.0.0") + ))); + testLocalRepository.signAllArtifacts(pgpValidKeys); + } + + private void prepareRequiredArtifacts() throws Exception { + final String galleonPluginsVersion = BuildProperties.getProperty("version.org.wildfly.galleon-plugins"); + final String commonsIoVersion = BuildProperties.getProperty("version.commons-io"); + + testLocalRepository.resolveAndDeploy(new DefaultArtifact("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", "jar", galleonPluginsVersion)); + testLocalRepository.resolveAndDeploy(new DefaultArtifact("org.wildfly.galleon-plugins", "wildfly-config-gen", "jar", galleonPluginsVersion)); + testLocalRepository.resolveAndDeploy(new DefaultArtifact("commons-io", "commons-io", "jar", commonsIoVersion)); + + testLocalRepository.deploy( + new DefaultArtifact("org.test", "test-channel", "manifest", "yaml","1.0.0"), + new ChannelManifest("test-manifest", null, null, List.of( + new Stream("org.wildfly.galleon-plugins", "wildfly-config-gen", galleonPluginsVersion), + new Stream("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", galleonPluginsVersion), + new Stream("commons-io", "commons-io", commonsIoVersion), + new Stream("org.test", "pack-one", "1.0.0") + ))); + } +} diff --git a/prospero-common/src/main/java/org/wildfly/prospero/actions/FeaturesAddAction.java b/prospero-common/src/main/java/org/wildfly/prospero/actions/FeaturesAddAction.java index 925e1b175..4437025f0 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/actions/FeaturesAddAction.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/actions/FeaturesAddAction.java @@ -91,7 +91,7 @@ public class FeaturesAddAction { public FeaturesAddAction(MavenOptions mavenOptions, Path installDir, List repositories, Console console) throws MetadataException, ProvisioningException { this(mavenOptions, installDir, repositories, console, - new DefaultCandidateActionsFactory(installDir), + new DefaultCandidateActionsFactory(installDir, console), new FeaturePackTemplateManager(), new LicenseManager()); } @@ -741,15 +741,17 @@ interface CandidateActionsFactory { private static class DefaultCandidateActionsFactory implements CandidateActionsFactory { private final Path installDir; + private final Console console; - public DefaultCandidateActionsFactory(Path installDir) { + public DefaultCandidateActionsFactory(Path installDir, Console console) { this.installDir = installDir; + this.console = console; } @Override public PrepareCandidateAction newPrepareCandidateActionInstance( MavenSessionManager mavenSessionManager, ProsperoConfig prosperoConfig) throws OperationException { - return new PrepareCandidateAction(installDir, mavenSessionManager, prosperoConfig); + return new PrepareCandidateAction(installDir, mavenSessionManager, prosperoConfig, console); } @Override diff --git a/prospero-common/src/main/java/org/wildfly/prospero/actions/InstallationHistoryAction.java b/prospero-common/src/main/java/org/wildfly/prospero/actions/InstallationHistoryAction.java index 727508f19..1d256e073 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/actions/InstallationHistoryAction.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/actions/InstallationHistoryAction.java @@ -130,7 +130,7 @@ public void prepareRevert(SavedState savedState, MavenOptions mavenOptions, List .setSourceServerPath(installation) .build(); PrepareCandidateAction prepareCandidateAction = new PrepareCandidateAction(installation, - mavenSessionManager, revertMetadata.getProsperoConfig())) { + mavenSessionManager, revertMetadata.getProsperoConfig(), console)) { System.setProperty(MAVEN_REPO_LOCAL, mavenSessionManager.getProvisioningRepo().toAbsolutePath().toString()); diff --git a/prospero-common/src/main/java/org/wildfly/prospero/actions/PrepareCandidateAction.java b/prospero-common/src/main/java/org/wildfly/prospero/actions/PrepareCandidateAction.java index edbfd2199..41fb3d7ff 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/actions/PrepareCandidateAction.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/actions/PrepareCandidateAction.java @@ -23,8 +23,10 @@ import org.wildfly.channel.Channel; import org.wildfly.channel.ChannelManifest; import org.wildfly.channel.UnresolvedMavenArtifactException; +import org.wildfly.channel.gpg.GpgSignatureValidator; import org.wildfly.prospero.ProsperoLogger; import org.wildfly.prospero.api.ArtifactChange; +import org.wildfly.prospero.api.Console; import org.wildfly.prospero.api.InstallationMetadata; import org.wildfly.prospero.api.SavedState; import org.wildfly.prospero.api.exceptions.ArtifactResolutionException; @@ -38,6 +40,9 @@ import org.wildfly.prospero.metadata.ManifestVersionRecord; import org.wildfly.prospero.metadata.ProsperoMetadataUtils; import org.wildfly.prospero.model.ProsperoConfig; +import org.wildfly.prospero.signatures.ConfirmingKeystoreAdapter; +import org.wildfly.prospero.signatures.KeystoreManager; +import org.wildfly.prospero.signatures.PGPLocalKeystore; import org.wildfly.prospero.updates.CandidateProperties; import org.wildfly.prospero.updates.CandidatePropertiesParser; import org.wildfly.prospero.updates.MarkerFile; @@ -62,13 +67,16 @@ class PrepareCandidateAction implements AutoCloseable { private final ProsperoConfig prosperoConfig; private final MavenSessionManager mavenSessionManager; private final Path installDir; + private final Console console; - PrepareCandidateAction(Path installDir, MavenSessionManager mavenSessionManager, ProsperoConfig prosperoConfig) + PrepareCandidateAction(Path installDir, MavenSessionManager mavenSessionManager, ProsperoConfig prosperoConfig, + Console console) throws OperationException { this.metadata = InstallationMetadata.loadInstallation(installDir); this.installDir = installDir; this.prosperoConfig = prosperoConfig; this.mavenSessionManager = mavenSessionManager; + this.console = console; } boolean buildCandidate(Path targetDir, GalleonEnvironment galleonEnv, ApplyCandidateAction.Type operation, @@ -152,8 +160,9 @@ private void doBuildUpdate(Path targetDir, GalleonEnvironment galleonEnv, Galleo manifestRecord); try { - final GalleonFeaturePackAnalyzer galleonFeaturePackAnalyzer = new GalleonFeaturePackAnalyzer(galleonEnv.getChannels(), mavenSessionManager); - galleonFeaturePackAnalyzer.cacheGalleonArtifacts(targetDir, provisioningConfig); + final GalleonFeaturePackAnalyzer galleonFeaturePackAnalyzer = new GalleonFeaturePackAnalyzer(galleonEnv.getChannels(), mavenSessionManager, console, + installDir.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg")); + galleonFeaturePackAnalyzer.cacheGalleonArtifacts(targetDir, installDir, provisioningConfig); } catch (Exception e) { throw new RuntimeException(e); } @@ -166,10 +175,12 @@ private void doBuildUpdate(Path targetDir, GalleonEnvironment galleonEnv, Galleo } private Optional getManifestVersionRecord(List channels) { - final ProsperoManifestVersionResolver manifestResolver = new ProsperoManifestVersionResolver(mavenSessionManager); - try { + try (PGPLocalKeystore keystore = KeystoreManager.keystoreFor( + installDir.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg"))) { + final ProsperoManifestVersionResolver manifestResolver = new ProsperoManifestVersionResolver(mavenSessionManager, + new GpgSignatureValidator(new ConfirmingKeystoreAdapter(keystore, console::acceptPublicKey))); return Optional.of(manifestResolver.getCurrentVersions(channels)); - } catch (IOException e) { + } catch (MetadataException | IOException e) { ProsperoLogger.ROOT_LOGGER.debug("Unable to retrieve current manifest versions", e); return Optional.empty(); } diff --git a/prospero-common/src/main/java/org/wildfly/prospero/actions/ProsperoManifestVersionResolver.java b/prospero-common/src/main/java/org/wildfly/prospero/actions/ProsperoManifestVersionResolver.java index 190d9f8e5..6fb24ee4a 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/actions/ProsperoManifestVersionResolver.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/actions/ProsperoManifestVersionResolver.java @@ -22,6 +22,7 @@ import org.wildfly.channel.ChannelManifestMapper; import org.wildfly.channel.MavenArtifact; import org.wildfly.channel.MavenCoordinate; +import org.wildfly.channel.spi.SignatureValidator; import org.wildfly.prospero.metadata.ManifestVersionRecord; import org.wildfly.prospero.metadata.ManifestVersionResolver; import org.wildfly.prospero.wfchannel.MavenSessionManager; @@ -45,11 +46,12 @@ class ProsperoManifestVersionResolver { private final Supplier manifestVersionResolver; - ProsperoManifestVersionResolver(MavenSessionManager mavenSessionManager) { + ProsperoManifestVersionResolver(MavenSessionManager mavenSessionManager, SignatureValidator signatureValidator) { this.manifestVersions = mavenSessionManager.getResolvedArtifactVersions(); this.manifestVersionResolver = () -> new ManifestVersionResolver( mavenSessionManager.getProvisioningRepo(), - mavenSessionManager.newRepositorySystem()); + mavenSessionManager.newRepositorySystem(), + signatureValidator); } ProsperoManifestVersionResolver(ResolvedArtifactsStore manifestVersions, ManifestVersionResolver manifestVersionResolver) { diff --git a/prospero-common/src/main/java/org/wildfly/prospero/actions/ProvisioningAction.java b/prospero-common/src/main/java/org/wildfly/prospero/actions/ProvisioningAction.java index 98d96d974..468e9e550 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/actions/ProvisioningAction.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/actions/ProvisioningAction.java @@ -190,7 +190,8 @@ public void provision(GalleonProvisioningConfig provisioningConfig, List getPendingLicenses(GalleonProvisioningConfig provisioningCo Objects.requireNonNull(provisioningConfig); Objects.requireNonNull(channels); - final GalleonFeaturePackAnalyzer exporter = new GalleonFeaturePackAnalyzer(channels, mavenSessionManager); + final GalleonFeaturePackAnalyzer exporter = new GalleonFeaturePackAnalyzer(channels, mavenSessionManager, + console, getKeyringPath()); return getPendingLicenses(provisioningConfig, exporter); } diff --git a/prospero-common/src/main/java/org/wildfly/prospero/actions/UpdateAction.java b/prospero-common/src/main/java/org/wildfly/prospero/actions/UpdateAction.java index 7b1e5c4c1..cf3ff2a51 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/actions/UpdateAction.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/actions/UpdateAction.java @@ -120,7 +120,7 @@ public boolean buildUpdate(Path targetDir) throws ProvisioningException, Operati } ProsperoLogger.ROOT_LOGGER.updateCandidateStarted(installDir); - try (PrepareCandidateAction prepareCandidateAction = new PrepareCandidateAction(installDir, mavenSessionManager, prosperoConfig); + try (PrepareCandidateAction prepareCandidateAction = new PrepareCandidateAction(installDir, mavenSessionManager, prosperoConfig, console); GalleonEnvironment galleonEnv = getGalleonEnv(targetDir)) { try (Provisioning p = new GalleonBuilder().newProvisioningBuilder(PathsUtils.getProvisioningXml(installDir)).build()) { final GalleonProvisioningConfig provisioningConfig = p.loadProvisioningConfig(PathsUtils.getProvisioningXml(installDir)); diff --git a/prospero-common/src/main/java/org/wildfly/prospero/galleon/GalleonFeaturePackAnalyzer.java b/prospero-common/src/main/java/org/wildfly/prospero/galleon/GalleonFeaturePackAnalyzer.java index 184b67012..85da6b331 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/galleon/GalleonFeaturePackAnalyzer.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/galleon/GalleonFeaturePackAnalyzer.java @@ -25,6 +25,7 @@ import org.wildfly.channel.Channel; import org.wildfly.channel.MavenArtifact; import org.wildfly.channel.UnresolvedMavenArtifactException; +import org.wildfly.prospero.api.Console; import org.wildfly.prospero.api.exceptions.OperationException; import org.wildfly.prospero.wfchannel.MavenSessionManager; @@ -44,39 +45,32 @@ public class GalleonFeaturePackAnalyzer { private final List channels; private final MavenSessionManager mavenSessionManager; + private final Console console; + private final Path keystore; - public GalleonFeaturePackAnalyzer(List channels, MavenSessionManager mavenSessionManager) { + public GalleonFeaturePackAnalyzer(List channels, MavenSessionManager mavenSessionManager, Console console, Path keystore) { this.channels = channels; this.mavenSessionManager = mavenSessionManager; + this.console = console; + this.keystore = keystore; } - /** - * Analyzes provisioning information found in {@code installedDir} and caches {@code FeaturePack} and Galleon plugin - * artifacts. - * - * This complements caching done in Wildfly Galleon Plugin}, - * as Galleon plugin is not able to access FeaturePack information. The discovered artifacts are cached using {@link ArtifactCache}. - * - * @param installedDir - path to the installation. Used to access the cache - * @param provisioningConfig - Galleon configuration to analyze - */ - public void cacheGalleonArtifacts(Path installedDir, GalleonProvisioningConfig provisioningConfig) throws Exception { + public void cacheGalleonArtifacts(Path installedDir, Path sourceDir, GalleonProvisioningConfig provisioningConfig) throws Exception { // no data will be actually written out, but we need a path to init the Galleon final Path tempInstallationPath = Files.createTempDirectory("temp"); final Set fps = new HashSet<>(); - try (GalleonEnvironment galleonEnv = galleonEnvWithFpMapper(tempInstallationPath, installedDir, fps, provisioningConfig)) { + try (GalleonEnvironment galleonEnv = galleonEnvWithFpMapper(tempInstallationPath, sourceDir, fps, provisioningConfig)) { final ArtifactCache artifactCache = ArtifactCache.getInstance(installedDir); - try (Provisioning pm = galleonEnv.getProvisioning()) { - final Set pluginGavs = pm.getOrderedFeaturePackPluginLocations(provisioningConfig); - for (String pluginGav : pluginGavs) { - final String[] pluginLoc = pluginGav.split(":"); - final MavenArtifact jar = galleonEnv.getChannelSession().resolveMavenArtifact(pluginLoc[0], pluginLoc[1], "jar", null, null); - artifactCache.cache(jar); - } + final Provisioning pm = galleonEnv.getProvisioning(); + final Set pluginGavs = pm.getOrderedFeaturePackPluginLocations(provisioningConfig); + for (String pluginGav : pluginGavs) { + final String[] pluginLoc = pluginGav.split(":"); + final MavenArtifact jar = galleonEnv.getChannelSession().resolveMavenArtifact(pluginLoc[0], pluginLoc[1], "jar", null, null); + artifactCache.cache(jar); } - for (String fp : getFeaturePacks(installedDir, provisioningConfig)) { + for (String fp : getFeaturePacks(sourceDir, provisioningConfig)) { // resolve the artifact final String[] fpLoc = fp.split(":"); final MavenArtifact mavenArtifact = galleonEnv.getChannelSession().resolveMavenArtifact(fpLoc[0], fpLoc[1], "zip", null, null); @@ -139,10 +133,11 @@ public Set getFeaturePacks(Path installedDir, GalleonProvisioningConfig private GalleonEnvironment galleonEnvWithFpMapper(Path tempInstallationPath, Path sourcePath, Set fps, GalleonProvisioningConfig provisioningConfig) throws ProvisioningException, OperationException { final GalleonEnvironment galleonEnv = GalleonEnvironment .builder(tempInstallationPath, channels, mavenSessionManager, false) - .setConsole(null) + .setConsole(console) .setSourceServerPath(sourcePath) .setProvisioningConfig(provisioningConfig) .setResolvedFpTracker(fps::add) + .setKeyringLocation(keystore) .build(); return galleonEnv; } diff --git a/prospero-common/src/test/java/org/wildfly/prospero/galleon/GalleonFeaturePackAnalyzerTest.java b/prospero-common/src/test/java/org/wildfly/prospero/galleon/GalleonFeaturePackAnalyzerTest.java index 57c445f08..e1569f434 100644 --- a/prospero-common/src/test/java/org/wildfly/prospero/galleon/GalleonFeaturePackAnalyzerTest.java +++ b/prospero-common/src/test/java/org/wildfly/prospero/galleon/GalleonFeaturePackAnalyzerTest.java @@ -53,7 +53,7 @@ public void featurePackDependencyIsIncluded() throws Exception { final List channels = List.of(new Channel.Builder() .addRepository("local-test", repoHome.toUri().toString()) .build()); - final Set featurePacks = new GalleonFeaturePackAnalyzer(channels, msm).getFeaturePacks(temp.newFile().toPath(), provisioningConfig); + final Set featurePacks = new GalleonFeaturePackAnalyzer(channels, msm, null, null).getFeaturePacks(temp.newFile().toPath(), provisioningConfig); assertThat(featurePacks) .containsOnly("org.test:pack-two", "org.test:pack-one"); } From 8a00219c8bd80a846ab9f801331cf0ed752cf164 Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Thu, 12 Sep 2024 17:19:49 +0100 Subject: [PATCH 08/11] Support signatures when reverting a server --- .../it/signatures/MixedChannelTestCase.java | 136 ++++++++++++ .../it/signatures/RevertTestCase.java | 208 ++++++++++++++++++ .../prospero/galleon/GalleonEnvironment.java | 37 +++- 3 files changed, 374 insertions(+), 7 deletions(-) create mode 100644 integration-tests/src/test/java/org/wildfly/prospero/it/signatures/MixedChannelTestCase.java create mode 100644 integration-tests/src/test/java/org/wildfly/prospero/it/signatures/RevertTestCase.java diff --git a/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/MixedChannelTestCase.java b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/MixedChannelTestCase.java new file mode 100644 index 000000000..ab7a757d2 --- /dev/null +++ b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/MixedChannelTestCase.java @@ -0,0 +1,136 @@ +package org.wildfly.prospero.it.signatures; + +import java.io.File; +import java.net.URL; +import java.nio.file.Path; +import java.util.List; + +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.eclipse.aether.artifact.Artifact; +import org.eclipse.aether.artifact.DefaultArtifact; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.wildfly.channel.Channel; +import org.wildfly.channel.ChannelManifest; +import org.wildfly.channel.ChannelManifestCoordinate; +import org.wildfly.channel.Stream; +import org.wildfly.prospero.test.BuildProperties; +import org.wildfly.prospero.test.CertificateUtils; +import org.wildfly.prospero.test.TestInstallation; +import org.wildfly.prospero.test.TestLocalRepository; + +public class MixedChannelTestCase { + protected static final String COMMONS_IO_VERSION1 = BuildProperties.getProperty("version.commons-io"); + protected static final String COMMONS_CODEC_VERSION = BuildProperties.getProperty("version.commons-codec"); + protected static final String GALLEON_PLUGINS_VERSION = BuildProperties.getProperty("version.org.wildfly.galleon-plugins"); + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + private TestLocalRepository testLocalRepositoryOne; + private TestLocalRepository testLocalRepositoryTwo; + private PGPSecretKeyRing pgpValidKeys; + private File certFile; + private String COMMONS_IO_VERSION; + private List channels; + private Path serverPath; + private TestInstallation testInstallation; + + @Before + public void setUp() throws Exception { + COMMONS_IO_VERSION = BuildProperties.getProperty("version.commons-io"); + testLocalRepositoryOne = new TestLocalRepository(temp.newFolder("local-repo-one").toPath(), + List.of(new URL("https://repo1.maven.org/maven2"))); + testLocalRepositoryTwo = new TestLocalRepository(temp.newFolder("local-repo-two").toPath(), + List.of(new URL("https://repo1.maven.org/maven2"))); + + pgpValidKeys = CertificateUtils.generatePrivateKey(); + certFile = CertificateUtils.exportPublicCertificate(pgpValidKeys, temp.newFile("public.crt")); + + testLocalRepositoryOne.resolveAndDeploy(new DefaultArtifact("org.wildfly.galleon-plugins", "wildfly-config-gen", "jar", GALLEON_PLUGINS_VERSION)); + testLocalRepositoryOne.resolveAndDeploy(new DefaultArtifact("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", "jar", GALLEON_PLUGINS_VERSION)); + testLocalRepositoryOne.resolveAndDeploy(new DefaultArtifact("commons-io", "commons-io", "jar", COMMONS_IO_VERSION1)); + testLocalRepositoryOne.deployMockUpdate("commons-io", "commons-io", COMMONS_IO_VERSION, ".SP1"); + + testLocalRepositoryTwo.resolveAndDeploy(new DefaultArtifact("commons-codec", "commons-codec", "jar", COMMONS_CODEC_VERSION)); + testLocalRepositoryTwo.deployMockUpdate("commons-codec", "commons-codec", COMMONS_CODEC_VERSION, ".SP1"); + + channels = List.of( + new Channel.Builder() + .setName("test-channel") + .setGpgCheck(true) + .addGpgUrl(certFile.toURI().toString()) + .addRepository("local-repo", testLocalRepositoryOne.getUri().toString()) + .setManifestCoordinate(new ChannelManifestCoordinate("org.test", "test-channel")) + .build(), + new Channel.Builder() + .setName("test-channel-two") + .addRepository("local-repo", testLocalRepositoryTwo.getUri().toString()) + .setManifestCoordinate(new ChannelManifestCoordinate("org.test", "test-channel-two")) + .build() + ); + + serverPath = temp.newFolder("server").toPath(); + testInstallation = new TestInstallation(serverPath); + } + + @Test + public void installUpdateAndRevertUsingMixedChannels() throws Exception { + // create FP with two modules + final Artifact featurePack = TestInstallation.fpBuilder("org.test:pack-one:1.0.0") + .addModule("commons-io", "commons-io", COMMONS_IO_VERSION) + .addModule("commons-codec", "commons-codec", "1.17.1") + .build(); + testLocalRepositoryOne.deploy(featurePack); + + // create two repositories - one with GPG signatures, one without + testLocalRepositoryOne.deploy( + new DefaultArtifact("org.test", "test-channel", "manifest", "yaml","1.0.0"), + new ChannelManifest("test-manifest", null, null, List.of( + new Stream("org.wildfly.galleon-plugins", "wildfly-config-gen", GALLEON_PLUGINS_VERSION), + new Stream("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", GALLEON_PLUGINS_VERSION), + new Stream("commons-io", "commons-io", COMMONS_IO_VERSION1), + new Stream("org.test", "pack-one", "1.0.0") + ))); + testLocalRepositoryOne.signAllArtifacts(pgpValidKeys); + testLocalRepositoryTwo.deploy( + new DefaultArtifact("org.test", "test-channel-two", "manifest", "yaml","1.0.0"), + new ChannelManifest("test-manifest", null, null, List.of( + new Stream("commons-codec", "commons-codec", COMMONS_CODEC_VERSION) + ))); + + + // install the server + testInstallation.install("org.test:pack-one:1.0.0", channels); + + testInstallation.verifyModuleJar("commons-io", "commons-io", COMMONS_IO_VERSION1); + testInstallation.verifyModuleJar("commons-codec", "commons-codec", COMMONS_CODEC_VERSION); + + // perform update + testLocalRepositoryOne.deploy( + new DefaultArtifact("org.test", "test-channel", "manifest", "yaml","1.0.1"), + new ChannelManifest("test-manifest", null, null, List.of( + new Stream("org.wildfly.galleon-plugins", "wildfly-config-gen", GALLEON_PLUGINS_VERSION), + new Stream("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", GALLEON_PLUGINS_VERSION), + new Stream("commons-io", "commons-io", COMMONS_IO_VERSION1 + ".SP1"), + new Stream("org.test", "pack-one", "1.0.0") + ))); + testLocalRepositoryOne.signAllArtifacts(pgpValidKeys); + testLocalRepositoryTwo.deploy( + new DefaultArtifact("org.test", "test-channel-two", "manifest", "yaml","1.0.1"), + new ChannelManifest("test-manifest", null, null, List.of( + new Stream("commons-codec", "commons-codec", COMMONS_CODEC_VERSION + ".SP1") + ))); + + testInstallation.update(); + + testInstallation.verifyModuleJar("commons-io", "commons-io", COMMONS_IO_VERSION1 + ".SP1"); + testInstallation.verifyModuleJar("commons-codec", "commons-codec", COMMONS_CODEC_VERSION + ".SP1"); + + // perform revert to original state + testInstallation.revertToOriginalState(); + + testInstallation.verifyModuleJar("commons-io", "commons-io", COMMONS_IO_VERSION1); + testInstallation.verifyModuleJar("commons-codec", "commons-codec", COMMONS_CODEC_VERSION); + } +} diff --git a/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/RevertTestCase.java b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/RevertTestCase.java new file mode 100644 index 000000000..48e187c04 --- /dev/null +++ b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/RevertTestCase.java @@ -0,0 +1,208 @@ +/* + * 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.prospero.it.signatures; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.catchThrowable; +import static org.wildfly.prospero.test.CertificateUtils.result; + +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; + +import org.apache.commons.io.FileUtils; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.eclipse.aether.artifact.DefaultArtifact; +import org.eclipse.aether.deployment.DeploymentException; +import org.eclipse.aether.resolution.ArtifactResolutionException; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.wildfly.channel.Channel; +import org.wildfly.channel.ChannelManifest; +import org.wildfly.channel.ChannelManifestCoordinate; +import org.wildfly.channel.Stream; +import org.wildfly.channel.spi.SignatureResult; +import org.wildfly.channel.spi.SignatureValidator; +import org.wildfly.prospero.it.AcceptingConsole; +import org.wildfly.prospero.it.utils.DirectoryComparator; +import org.wildfly.prospero.metadata.ProsperoMetadataUtils; +import org.wildfly.prospero.test.BuildProperties; +import org.wildfly.prospero.test.CertificateUtils; +import org.wildfly.prospero.test.TestInstallation; +import org.wildfly.prospero.test.TestLocalRepository; + +public class RevertTestCase { + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + private TestLocalRepository testLocalRepository; + private TestInstallation testInstallation; + private Path serverPath; + private PGPSecretKeyRing pgpValidKeys; + private File certFile; + private String COMMONS_IO_VERSION; + + @Before + public void setUp() throws Exception { + COMMONS_IO_VERSION = BuildProperties.getProperty("version.commons-io"); + testLocalRepository = new TestLocalRepository(temp.newFolder("local-repo").toPath(), + List.of(new URL("https://repo1.maven.org/maven2"))); + + prepareRequiredArtifacts(testLocalRepository); + + serverPath = temp.newFolder("server").toPath(); + testInstallation = new TestInstallation(serverPath); + + testLocalRepository.deploy(TestInstallation.fpBuilder("org.test:pack-one:1.0.0") + .addModule("commons-io", "commons-io", "2.16.1") + .build()); + pgpValidKeys = CertificateUtils.generatePrivateKey(); + testLocalRepository.signAllArtifacts(pgpValidKeys); + + certFile = CertificateUtils.exportPublicCertificate(pgpValidKeys, temp.newFile("public.crt")); + final Channel testChannel = new Channel.Builder() + .setName("test-channel") + .setGpgCheck(true) + .addGpgUrl(certFile.toURI().toString()) + .addRepository("local-repo", testLocalRepository.getUri().toString()) + .setManifestCoordinate(new ChannelManifestCoordinate("org.test", "test-channel")) + .build(); + + testInstallation.install("org.test:pack-one:1.0.0", List.of(testChannel)); + + publishUpdate(); + testInstallation.update(); + } + + @Test + public void revertToOriginalInstallation() throws Exception { + testInstallation.revertToOriginalState(); + + testInstallation.verifyModuleJar("commons-io", "commons-io", COMMONS_IO_VERSION); + CertificateUtils.assertKeystoreContainsOnly(serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg"), pgpValidKeys.getPublicKey().getKeyID()); + } + + @Test + public void revertToOriginalInstallation_RemovedKeystoreAsksForConfirmation() throws Exception { + // remove a keyring so we need to accept it again + Files.delete(serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg")); + + testInstallation.revertToOriginalState(); + testInstallation.verifyModuleJar("commons-io", "commons-io", COMMONS_IO_VERSION); + CertificateUtils.assertKeystoreContainsOnly(serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg"), pgpValidKeys.getPublicKey().getKeyID()); + } + + @Test + public void revertToOriginalInstallation_RemovedKeystoreRejectedConfirmation_NoChanges() throws Exception { + // remove a keyring so we need to accept it again + Files.delete(serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg")); + + final Path originalServer = temp.newFolder("original-server").toPath(); + FileUtils.copyDirectory(serverPath.toFile(), originalServer.toFile()); + + Throwable exception = catchThrowable(()-> testInstallation.revertToOriginalState(new AcceptingConsole() { + @Override + public boolean acceptPublicKey(String key) { + return false; + } + })); + assertThat(exception) + .isInstanceOf(SignatureValidator.SignatureException.class) + .has(result((SignatureValidator.SignatureException) exception, SignatureResult.Result.NO_MATCHING_CERT)); + + DirectoryComparator.assertNoChanges(originalServer, serverPath, + Path.of(ProsperoMetadataUtils.METADATA_DIR, "keyring.gpg"), + Path.of(ProsperoMetadataUtils.METADATA_DIR, ".git", "ORIG_HEAD")); + } + + @Test + public void revertWithOfflineRepository_DoesNotRequireSignatures() throws Exception { + // remove a keyring so we need to accept it again + final Path keystoreLocation = serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg"); + Files.delete(keystoreLocation); + + TestLocalRepository testLocalRepositoryTwo = new TestLocalRepository(temp.newFolder("repo-two").toPath(), + List.of(new URL("https://repo1.maven.org/maven2"))); + prepareRequiredArtifacts(testLocalRepositoryTwo); + + final Path originalServer = temp.newFolder("original-server").toPath(); + FileUtils.copyDirectory(serverPath.toFile(), originalServer.toFile()); + + testInstallation.revertToOriginalState(new AcceptingConsole() { + @Override + public boolean acceptPublicKey(String key) { + return false; + } + }, List.of(testLocalRepositoryTwo.getUri().toURL())); + + testInstallation.verifyModuleJar("commons-io", "commons-io", COMMONS_IO_VERSION); + DirectoryComparator.assertNoChanges(originalServer, serverPath, + Path.of(ProsperoMetadataUtils.METADATA_DIR, "keyring.gpg"), + Path.of(".galleon"), + Path.of(ProsperoMetadataUtils.METADATA_DIR, ProsperoMetadataUtils.MANIFEST_FILE_NAME), + Path.of(ProsperoMetadataUtils.METADATA_DIR, ProsperoMetadataUtils.CURRENT_VERSION_FILE), + Path.of(ProsperoMetadataUtils.METADATA_DIR, ".git"), + Path.of(ProsperoMetadataUtils.METADATA_DIR, ".cache"), + Path.of("modules", "commons-io") + ); + if (Files.exists(keystoreLocation)) { + CertificateUtils.assertKeystoreIsEmpty(keystoreLocation); + } + } + + + private void publishUpdate() throws ArtifactResolutionException, DeploymentException, IOException { + final String galleonPluginsVersion = BuildProperties.getProperty("version.org.wildfly.galleon-plugins"); + final String commonsIoVersion = BuildProperties.getProperty("version.commons-io"); + + testLocalRepository.deployMockUpdate("commons-io", "commons-io", commonsIoVersion, ".SP1"); + + testLocalRepository.deploy( + new DefaultArtifact("org.test", "test-channel", "manifest", "yaml","1.0.1"), + new ChannelManifest("test-manifest", null, null, List.of( + new Stream("org.wildfly.galleon-plugins", "wildfly-config-gen", galleonPluginsVersion), + new Stream("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", galleonPluginsVersion), + new Stream("commons-io", "commons-io", commonsIoVersion + ".SP1"), + new Stream("org.test", "pack-one", "1.0.0") + ))); + testLocalRepository.signAllArtifacts(pgpValidKeys); + } + + private void prepareRequiredArtifacts(TestLocalRepository localRepository) throws Exception { + final String galleonPluginsVersion = BuildProperties.getProperty("version.org.wildfly.galleon-plugins"); + final String commonsIoVersion = BuildProperties.getProperty("version.commons-io"); + + localRepository.resolveAndDeploy(new DefaultArtifact("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", "jar", galleonPluginsVersion)); + localRepository.resolveAndDeploy(new DefaultArtifact("org.wildfly.galleon-plugins", "wildfly-config-gen", "jar", galleonPluginsVersion)); + localRepository.resolveAndDeploy(new DefaultArtifact("commons-io", "commons-io", "jar", commonsIoVersion)); + + localRepository.deploy( + new DefaultArtifact("org.test", "test-channel", "manifest", "yaml","1.0.0"), + new ChannelManifest("test-manifest", null, null, List.of( + new Stream("org.wildfly.galleon-plugins", "wildfly-config-gen", galleonPluginsVersion), + new Stream("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", galleonPluginsVersion), + new Stream("commons-io", "commons-io", commonsIoVersion), + new Stream("org.test", "pack-one", "1.0.0") + ))); + } +} diff --git a/prospero-common/src/main/java/org/wildfly/prospero/galleon/GalleonEnvironment.java b/prospero-common/src/main/java/org/wildfly/prospero/galleon/GalleonEnvironment.java index 09d1bbda7..006903945 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/galleon/GalleonEnvironment.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/galleon/GalleonEnvironment.java @@ -48,6 +48,7 @@ import org.wildfly.prospero.signatures.PGPLocalKeystore; import org.wildfly.prospero.wfchannel.MavenSessionManager; +import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.net.MalformedURLException; @@ -87,18 +88,20 @@ public class GalleonEnvironment implements AutoCloseable { private boolean resetGalleonLineEndings = true; private PGPLocalKeystore localGpgKeystore; + private TemporaryManifestSignature temporaryManifestSignature; private GalleonEnvironment(Builder builder) throws ProvisioningException, MetadataException, ChannelDefinitionException, UnresolvedChannelMetadataException { Optional console = Optional.ofNullable(builder.console); Optional restoreManifest = Optional.ofNullable(builder.manifest); + final Path sourceServerPath = builder.sourceServerPath == null? builder.installDir:builder.sourceServerPath; if (restoreManifest.isPresent()) { if (LOG.isDebugEnabled()) { LOG.debug("Replacing channel manifests with restore manifest"); } - channels = replaceManifestWithRestoreManifests(builder, restoreManifest); + channels = replaceManifestWithRestoreManifests(builder, restoreManifest, sourceServerPath); } else { channels = builder.channels; } @@ -109,17 +112,14 @@ private GalleonEnvironment(Builder builder) throws ProvisioningException, Metada substitutedChannels.add(substitutor.substitute(channel)); } - // if console is null, we're rejecting all new certs!! - - final Path sourceServerPath = builder.sourceServerPath == null? builder.installDir:builder.sourceServerPath; - LOG.debug("Using keystore location: " + buildKeystoreLocation(builder, sourceServerPath)); localGpgKeystore = KeystoreManager.keystoreFor(buildKeystoreLocation(builder, sourceServerPath)); final RepositorySystem system = builder.mavenSessionManager.newRepositorySystem(); final DefaultRepositorySystemSession session = builder.mavenSessionManager.newRepositorySystemSession(system); - final GpgSignatureValidator signatureValidator = new GpgSignatureValidator(new ConfirmingKeystoreAdapter(localGpgKeystore, chooseCertificateAcceptor(console))); + final GpgSignatureValidator signatureValidator = new GpgSignatureValidator(new ConfirmingKeystoreAdapter(localGpgKeystore, + chooseCertificateAcceptor(console))); MavenVersionsResolver.Factory factory; try { @@ -172,6 +172,7 @@ private GalleonEnvironment(Builder builder) throws ProvisioningException, Metada } private static Function chooseCertificateAcceptor(Optional console) { + // if console is null, we're rejecting all new certs final Function acceptor; if (console.isPresent()) { acceptor = console.get()::acceptPublicKey; @@ -221,7 +222,8 @@ private static void storeOriginalChannelManifestAsResolved(Builder builder, Mave } } - private List replaceManifestWithRestoreManifests(Builder builder, Optional restoreManifest) throws ProvisioningException { + private List replaceManifestWithRestoreManifests(Builder builder, Optional restoreManifest, + Path sourceServerPath) throws ProvisioningException { ChannelManifestCoordinate manifestCoord; try { restoreManifestPath = Files.createTempFile("prospero-restore-manifest", "yaml"); @@ -231,6 +233,13 @@ private List replaceManifestWithRestoreManifests(Builder builder, Optio } Files.writeString(restoreManifestPath, ChannelManifestMapper.toYaml(restoreManifest.get())); manifestCoord = new ChannelManifestCoordinate(restoreManifestPath.toUri().toURL()); + + /** + * if the channel is using signatures, we need to generate a signature for the manifest to validate it later on + */ + if (builder.channels.stream().anyMatch(Channel::isGpgCheck)) { + selfSignRestoreManifest(sourceServerPath, builder.keyringLocation); + } } catch (IOException e) { throw ProsperoLogger.ROOT_LOGGER.unableToCreateTemporaryFile(e); } @@ -250,6 +259,17 @@ private List replaceManifestWithRestoreManifests(Builder builder, Optio return channels; } + private void selfSignRestoreManifest(Path sourceServerPath, Path keyringLocation) { + try { + final Path keystoreLocation = keyringLocation != null ? keyringLocation : sourceServerPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg"); + temporaryManifestSignature = new TemporaryManifestSignature(keystoreLocation); + final File signature = restoreManifestPath.getParent().resolve(restoreManifestPath.getFileName().toString() + ".asc").toFile(); + temporaryManifestSignature.sign(restoreManifestPath.toFile(), signature); + } catch (Exception e){ + throw new RuntimeException("Unable to generate self-signing revert certificate: " + e.getMessage(), e); + } + } + private ChannelSession initChannelSession(DefaultRepositorySystemSession session, MavenVersionsResolver.Factory factory) throws UnresolvedChannelMetadataException, ChannelDefinitionException { final ChannelSession channelSession; try { @@ -300,6 +320,9 @@ public void close() { if (restoreManifestPath != null) { FileUtils.deleteQuietly(restoreManifestPath.toFile()); } + if (temporaryManifestSignature != null) { + temporaryManifestSignature.close(); + } if (localGpgKeystore != null) { localGpgKeystore.close(); } From 59a53c6b15f070372ca1497b59de49625039ba08 Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Thu, 12 Sep 2024 22:41:11 +0100 Subject: [PATCH 09/11] Support server restore with GPG check --- .../it/signatures/RestoreTestCase.java | 135 ++++++++++++++++++ .../actions/InstallationRestoreAction.java | 18 +++ 2 files changed, 153 insertions(+) create mode 100644 integration-tests/src/test/java/org/wildfly/prospero/it/signatures/RestoreTestCase.java diff --git a/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/RestoreTestCase.java b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/RestoreTestCase.java new file mode 100644 index 000000000..d5a928dfe --- /dev/null +++ b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/RestoreTestCase.java @@ -0,0 +1,135 @@ +/* + * 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.prospero.it.signatures; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.net.URL; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; + +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.eclipse.aether.artifact.DefaultArtifact; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.wildfly.channel.Channel; +import org.wildfly.channel.ChannelManifest; +import org.wildfly.channel.ChannelManifestCoordinate; +import org.wildfly.channel.Stream; +import org.wildfly.channel.spi.SignatureValidator; +import org.wildfly.prospero.actions.InstallationExportAction; +import org.wildfly.prospero.actions.InstallationRestoreAction; +import org.wildfly.prospero.api.MavenOptions; +import org.wildfly.prospero.it.AcceptingConsole; +import org.wildfly.prospero.metadata.ProsperoMetadataUtils; +import org.wildfly.prospero.test.BuildProperties; +import org.wildfly.prospero.test.CertificateUtils; +import org.wildfly.prospero.test.TestInstallation; +import org.wildfly.prospero.test.TestLocalRepository; + +public class RestoreTestCase { + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + private TestLocalRepository testLocalRepository; + private TestInstallation testInstallation; + private Path serverPath; + private PGPSecretKeyRing pgpValidKeys; + private File certFile; + + @Before + public void setUp() throws Exception { + testLocalRepository = new TestLocalRepository(temp.newFolder("local-repo").toPath(), + List.of(new URL("https://repo1.maven.org/maven2"))); + + prepareRequiredArtifacts(); + + serverPath = temp.newFolder("server").toPath(); + testInstallation = new TestInstallation(serverPath); + + testLocalRepository.deploy(TestInstallation.fpBuilder("org.test:pack-one:1.0.0") + .addModule("commons-io", "commons-io", "2.16.1") + .build()); + pgpValidKeys = CertificateUtils.generatePrivateKey(); + testLocalRepository.signAllArtifacts(pgpValidKeys); + + certFile = CertificateUtils.exportPublicCertificate(pgpValidKeys, temp.newFile("public.crt")); + final Channel testChannel = new Channel.Builder() + .setName("test-channel") + .setGpgCheck(true) + .addGpgUrl(certFile.toURI().toString()) + .addRepository("local-repo", testLocalRepository.getUri().toString()) + .setManifestCoordinate(new ChannelManifestCoordinate("org.test", "test-channel")) + .build(); + + testInstallation.install("org.test:pack-one:1.0.0", List.of(testChannel)); + } + + @Test + public void restoreInstallsServerIfCertificateIsAccepted() throws Exception { + final Path exported = temp.newFile("exported.zip").toPath(); + new InstallationExportAction(serverPath).export(exported); + final Path restored = temp.getRoot().toPath().resolve("restored"); + final InstallationRestoreAction restoreAction = new InstallationRestoreAction(restored, MavenOptions.DEFAULT_OPTIONS, new AcceptingConsole()); + restoreAction.restore(exported, Collections.emptyList()); + + new TestInstallation(restored).verifyInstallationMetadataPresent(); + CertificateUtils.assertKeystoreContains(restored.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg"), + pgpValidKeys.getPublicKey().getKeyID()); + } + + @Test + public void doNothingIfCertificateIsRejected() throws Exception { + final Path exported = temp.newFile("exported.zip").toPath(); + new InstallationExportAction(serverPath).export(exported); + final Path restored = temp.getRoot().toPath().resolve("restored"); + final InstallationRestoreAction restoreAction = new InstallationRestoreAction(restored, MavenOptions.DEFAULT_OPTIONS, new AcceptingConsole() { + @Override + public boolean acceptPublicKey(String key) { + return false; + } + }); + + assertThatThrownBy(() -> restoreAction.restore(exported, Collections.emptyList())) + .isInstanceOf(SignatureValidator.SignatureException.class); + + assertThat(restored) + .doesNotExist(); + } + + private void prepareRequiredArtifacts() throws Exception { + final String galleonPluginsVersion = BuildProperties.getProperty("version.org.wildfly.galleon-plugins"); + final String commonsIoVersion = BuildProperties.getProperty("version.commons-io"); + + testLocalRepository.resolveAndDeploy(new DefaultArtifact("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", "jar", galleonPluginsVersion)); + testLocalRepository.resolveAndDeploy(new DefaultArtifact("org.wildfly.galleon-plugins", "wildfly-config-gen", "jar", galleonPluginsVersion)); + testLocalRepository.resolveAndDeploy(new DefaultArtifact("commons-io", "commons-io", "jar", commonsIoVersion)); + + testLocalRepository.deploy( + new DefaultArtifact("org.test", "test-channel", "manifest", "yaml","1.0.0"), + new ChannelManifest("test-manifest", null, null, List.of( + new Stream("org.wildfly.galleon-plugins", "wildfly-config-gen", galleonPluginsVersion), + new Stream("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", galleonPluginsVersion), + new Stream("commons-io", "commons-io", commonsIoVersion), + new Stream("org.test", "pack-one", "1.0.0") + ))); + } +} diff --git a/prospero-common/src/main/java/org/wildfly/prospero/actions/InstallationRestoreAction.java b/prospero-common/src/main/java/org/wildfly/prospero/actions/InstallationRestoreAction.java index 6f0c107a5..f7015b043 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/actions/InstallationRestoreAction.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/actions/InstallationRestoreAction.java @@ -17,6 +17,7 @@ package org.wildfly.prospero.actions; +import org.apache.commons.io.FileUtils; import org.wildfly.channel.Channel; import org.wildfly.channel.ChannelManifest; import org.wildfly.channel.Repository; @@ -31,11 +32,13 @@ import org.wildfly.prospero.api.InstallationMetadata; import org.wildfly.prospero.api.exceptions.MetadataException; import org.wildfly.prospero.galleon.GalleonUtils; +import org.wildfly.prospero.metadata.ProsperoMetadataUtils; import org.wildfly.prospero.model.ProsperoConfig; import org.wildfly.prospero.wfchannel.MavenSessionManager; import org.jboss.galleon.ProvisioningException; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -66,19 +69,34 @@ public void restore(Path metadataBundleZip, List remoteRepositories) prosperoConfig.getChannels().clear(); prosperoConfig.getChannels().addAll(TemporaryRepositoriesHandler.overrideRepositories(originalChannels, remoteRepositories)); } + + // if the channels require GPG checks, we need to create a temporary keystore, because we cannot write in + // the installation directory before it is provisioned + Path tempKeyringPath = null; + try { + tempKeyringPath = Files.createTempFile("keyring", "gpg"); + } catch (IOException e) { + throw ProsperoLogger.ROOT_LOGGER.unableToCreateTemporaryFile(e); + } try (GalleonEnvironment galleonEnv = GalleonEnvironment .builder(installDir, prosperoConfig.getChannels(), mavenSessionManager, false) .setConsole(console) .setRestoreManifest(metadataBundle.getManifest()) + .setKeyringLocation(tempKeyringPath) .build()) { GalleonUtils.executeGalleon(options -> galleonEnv.getProvisioning().provision(metadataBundle.getGalleonProvisioningConfig(), options), mavenSessionManager.getProvisioningRepo().toAbsolutePath()); + FileUtils.copyFile(tempKeyringPath.toFile(), installDir.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg").toFile()); writeProsperoMetadata(galleonEnv.getChannelSession().getRecordedChannel(), originalChannels); } catch (UnresolvedMavenArtifactException e) { throw new ArtifactResolutionException(ProsperoLogger.ROOT_LOGGER.unableToResolve(), e, e.getUnresolvedArtifacts(), e.getAttemptedRepositories(), mavenSessionManager.isOffline()); + } finally { + if (tempKeyringPath != null) { + FileUtils.deleteQuietly(tempKeyringPath.toFile()); + } } } } From 1ddb35cf165b1af30311dd27333322e940112b57 Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Fri, 11 Oct 2024 10:43:14 +0100 Subject: [PATCH 10/11] fix for 474ceba19b30802ace370231f55b3c24d919cc4e --- .../prospero/actions/FeaturesAddAction.java | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/prospero-common/src/main/java/org/wildfly/prospero/actions/FeaturesAddAction.java b/prospero-common/src/main/java/org/wildfly/prospero/actions/FeaturesAddAction.java index 4437025f0..2580cc463 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/actions/FeaturesAddAction.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/actions/FeaturesAddAction.java @@ -615,34 +615,36 @@ private Map> getAllLayers(FeaturePackLocation fpl) .addFeaturePackDep(GalleonFeaturePackConfig.builder(fpl).build()) .build(); - final MavenRepoManager repositoryManager = GalleonEnvironment - .builder(installDir, prosperoConfig.getChannels(), mavenSessionManager, false).build() - .getRepositoryManager(); - final Map> layersMap = new HashMap<>(); - try (Provisioning p = new GalleonBuilder().addArtifactResolver(repositoryManager).newProvisioningBuilder(config).build()) { - try (GalleonProvisioningLayout layout = p.newProvisioningLayout(config)) { - for (GalleonFeaturePackLayout fp : layout.getOrderedFeaturePacks()) { - final Set configIds; - try { - configIds = fp.loadLayers(); - } catch (IOException e) { - // this should not happen as the code IOException is not actually thrown by loadLayers - throw new RuntimeException(e); - } - for (ConfigId layer : configIds) { - final String model = layer.getModel(); - Set names = layersMap.get(model); - if (names == null) { - names = new HashSet<>(); - layersMap.put(model, names); + try (GalleonEnvironment env = GalleonEnvironment.builder(installDir, prosperoConfig.getChannels(), mavenSessionManager, false) + .setConsole(console) + .build()) { + final MavenRepoManager repositoryManager = env.getRepositoryManager(); + final Map> layersMap = new HashMap<>(); + try (Provisioning p = new GalleonBuilder().addArtifactResolver(repositoryManager).newProvisioningBuilder(config).build()) { + try (GalleonProvisioningLayout layout = p.newProvisioningLayout(config)) { + for (GalleonFeaturePackLayout fp : layout.getOrderedFeaturePacks()) { + final Set configIds; + try { + configIds = fp.loadLayers(); + } catch (IOException e) { + // this should not happen as the code IOException is not actually thrown by loadLayers + throw new RuntimeException(e); + } + for (ConfigId layer : configIds) { + final String model = layer.getModel(); + Set names = layersMap.get(model); + if (names == null) { + names = new HashSet<>(); + layersMap.put(model, names); + } + names.add(layer.getName()); } - names.add(layer.getName()); } } } + return layersMap; } - return layersMap; } private ProsperoConfig addTemporaryRepositories(List repositories) { From d0bcc2b05c29eb1b55b631a812b8f620cf26e890 Mon Sep 17 00:00:00 2001 From: Bartosz Spyrko-Smietanko Date: Fri, 11 Oct 2024 15:23:11 +0100 Subject: [PATCH 11/11] Add provisioning options to modify the GPG settings --- .../it/signatures/InstallationTestCase.java | 47 +++++++++- pom.xml | 2 +- .../wildfly/prospero/cli/ActionFactory.java | 4 +- .../org/wildfly/prospero/cli/CliMessages.java | 4 + .../prospero/cli/commands/CliConstants.java | 1 + .../prospero/cli/commands/InstallCommand.java | 91 ++++++++++++------- .../cli/commands/PrintLicensesCommand.java | 25 ++--- .../main/resources/UsageMessages.properties | 1 + .../cli/commands/InstallCommandTest.java | 59 +++++++++++- .../prospero/actions/ProvisioningAction.java | 50 +++++----- .../prospero/api/ProvisioningDefinition.java | 22 +++++ .../signatures/CachedPGPKeystore.java | 1 + .../api/ProvisioningDefinitionTest.java | 37 ++++++++ .../prospero-installation-profiles.yaml | 12 +++ 14 files changed, 284 insertions(+), 72 deletions(-) diff --git a/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/InstallationTestCase.java b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/InstallationTestCase.java index 612909dfa..e25d2d4da 100644 --- a/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/InstallationTestCase.java +++ b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/InstallationTestCase.java @@ -22,12 +22,15 @@ import java.io.File; import java.net.URL; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import org.assertj.core.api.Assertions; import org.bouncycastle.openpgp.PGPSecretKeyRing; import org.eclipse.aether.artifact.DefaultArtifact; +import org.jboss.galleon.api.config.GalleonProvisioningConfig; +import org.jboss.galleon.universe.FeaturePackLocation; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -37,8 +40,12 @@ import org.wildfly.channel.Stream; import org.wildfly.channel.spi.SignatureResult; import org.wildfly.channel.spi.SignatureValidator; +import org.wildfly.prospero.actions.ProvisioningAction; +import org.wildfly.prospero.api.MavenOptions; import org.wildfly.prospero.it.AcceptingConsole; import org.wildfly.prospero.metadata.ProsperoMetadataUtils; +import org.wildfly.prospero.signatures.KeystoreManager; +import org.wildfly.prospero.signatures.PGPLocalKeystore; import org.wildfly.prospero.test.BuildProperties; import org.wildfly.prospero.test.CertificateUtils; import org.wildfly.prospero.test.TestInstallation; @@ -46,7 +53,6 @@ public class InstallationTestCase { - // TODO: missing use case - install using custom keyring @Rule public TemporaryFolder temp = new TemporaryFolder(); private TestLocalRepository testLocalRepository; @@ -173,6 +179,45 @@ public boolean acceptPublicKey(String key) { CertificateUtils.assertKeystoreIsEmpty(serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg")); } + @Test + public void installUsingCustomKeystore_AcceptsKnownCerts() throws Exception { + // prepare a keyring with imported certificate + final Path keyring = temp.newFile("keystore.gpg").toPath(); + Files.delete(keyring); + try (PGPLocalKeystore pgpLocalKeystore = KeystoreManager.keystoreFor(keyring);) { + pgpLocalKeystore.importCertificate(List.of(pgpValidKeys.getPublicKey())); + } + + // create a channel that requires the GPG checks but has no certificate URLs information + final Channel testChannel = new Channel.Builder() + .setName("test-channel") + .setGpgCheck(true) + .addRepository("local-repo", testLocalRepository.getUri().toString()) + .setManifestCoordinate("org.test", "test-channel", "1.0.0") + .build(); + + // create a console that will reject any new signatures + final AcceptingConsole rejectingConsole = new AcceptingConsole() { + @Override + public boolean acceptPublicKey(String key) { + return false; + } + }; + + // finally, provision the server using keyring created at the beginning + try (ProvisioningAction action = new ProvisioningAction(serverPath, MavenOptions.OFFLINE_NO_CACHE, keyring, rejectingConsole)) { + action.provision(GalleonProvisioningConfig.builder() + .addFeaturePackDep(FeaturePackLocation.fromString("org.test:pack-one:1.0.0")) + .build(), List.of(testChannel)); + + } + + // and verify we did install the server + testInstallation.verifyModuleJar("commons-io", "commons-io", "2.16.1"); + testInstallation.verifyInstallationMetadataPresent(); + CertificateUtils.assertKeystoreContains(serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg"), pgpValidKeys.getPublicKey().getKeyID()); + } + private void prepareRequiredArtifacts(TestLocalRepository localRepository) throws Exception { final String galleonPluginsVersion = BuildProperties.getProperty("version.org.wildfly.galleon-plugins"); final String commonsIoVersion = BuildProperties.getProperty("version.commons-io"); diff --git a/pom.xml b/pom.xml index ab85ac76e..edc3fad4f 100644 --- a/pom.xml +++ b/pom.xml @@ -76,7 +76,7 @@ 2.2 4.13.2 3.6.0 - 1.1.1.Final-SNAPSHOT + 2.0.0.Final-SNAPSHOT 3.10.1 33.0.1.Final 4.7.6 diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/ActionFactory.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/ActionFactory.java index 51387c618..fe1bdb913 100644 --- a/prospero-cli/src/main/java/org/wildfly/prospero/cli/ActionFactory.java +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/ActionFactory.java @@ -40,8 +40,8 @@ public class ActionFactory { - public ProvisioningAction install(Path targetPath, MavenOptions mavenOptions, Console console) throws ProvisioningException { - return new ProvisioningAction(targetPath, mavenOptions, console); + public ProvisioningAction install(Path targetPath, MavenOptions mavenOptions, Path keystorePath, Console console) throws ProvisioningException { + return new ProvisioningAction(targetPath, mavenOptions, keystorePath, console); } // Option for BETA update support diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliMessages.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliMessages.java index 8e128e38f..3583a371a 100644 --- a/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliMessages.java +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/CliMessages.java @@ -800,4 +800,8 @@ default String certificateRevokePrompt() { default String certificateRevoked() { return bundle.getString("prospero.certificate.revoke.success"); } + + default ArgumentParsingException unableToReadKeyring(Path keyring, Exception cause) { + return new ArgumentParsingException(String.format("Unable to parse GPG keyring at %s: %s", keyring, cause.getMessage()), cause); + } } diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/CliConstants.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/CliConstants.java index 1d9f438f6..9a703aa54 100644 --- a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/CliConstants.java +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/CliConstants.java @@ -78,6 +78,7 @@ private Commands() { public static final String FEATURE_PACK_REFERENCE = ""; public static final String FPL = "--fpl"; public static final String GPG_CHECK = "--gpg-check"; + public static final String GPG_KEYSTORE = "--gpg-keystore"; public static final String H = "-h"; public static final String HELP = "--help"; public static final String KEY_ID= "--key-id"; diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/InstallCommand.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/InstallCommand.java index 9fbd7654c..18bb3e997 100644 --- a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/InstallCommand.java +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/InstallCommand.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.net.URL; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Iterator; @@ -60,6 +61,7 @@ import org.wildfly.prospero.galleon.GalleonUtils; import org.wildfly.prospero.licenses.License; import org.wildfly.prospero.model.InstallationProfile; +import org.wildfly.prospero.signatures.KeystoreManager; import picocli.CommandLine; import javax.xml.stream.XMLStreamException; @@ -96,6 +98,16 @@ public class InstallCommand extends AbstractInstallCommand { ) List shadowRepositories = new ArrayList<>(); + @CommandLine.Option( + names = CliConstants.GPG_CHECK + ) + Optional requireGpgCheck; + + @CommandLine.Option( + names = CliConstants.GPG_KEYSTORE + ) + Path gpgKeystore; + protected static final List STABILITY_LEVELS = List.of(Constants.STABILITY_EXPERIMENTAL, Constants.STABILITY_PREVIEW, Constants.STABILITY_DEFAULT, @@ -186,6 +198,17 @@ public Integer call() throws Exception { } } + if (gpgKeystore != null) { + if (!Files.exists(gpgKeystore)) { + throw CliMessages.MESSAGES.certificateNonExistingFilePath(gpgKeystore); + } + try { + KeystoreManager.keystoreFor(gpgKeystore).close(); + } catch (Exception e) { + throw CliMessages.MESSAGES.unableToReadKeyring(gpgKeystore, e); + } + } + stabilityLevels.verify(); if (featurePackOrDefinition.definition.isPresent()) { @@ -198,6 +221,7 @@ public Integer call() throws Exception { .setStabilityLevel(stabilityLevels.stabilityLevel==null?null:stabilityLevels.stabilityLevel.toLowerCase(Locale.ROOT)) .setPackageStabilityLevel(stabilityLevels.packageStabilityLevel==null?null:stabilityLevels.packageStabilityLevel.toLowerCase(Locale.ROOT)) .setConfigStabilityLevel(stabilityLevels.configStabilityLevel==null?null:stabilityLevels.configStabilityLevel.toLowerCase(Locale.ROOT)) + .setRequireGpgCheck(requireGpgCheck.orElse(null)) .build(); final MavenOptions mavenOptions = getMavenOptions(); final GalleonProvisioningConfig provisioningConfig = provisioningDefinition.toProvisioningConfig(); @@ -206,51 +230,52 @@ public Integer call() throws Exception { List repositories = RepositoryDefinition.from(this.shadowRepositories); final List shadowRepositories = RepositoryUtils.unzipArchives(repositories, temporaryFiles); - final ProvisioningAction provisioningAction = actionFactory.install(directory.toAbsolutePath(), mavenOptions, - console); - - if (featurePackOrDefinition.fpl.isPresent()) { - console.println(CliMessages.MESSAGES.installingFpl(featurePackOrDefinition.fpl.get())); - } else if (featurePackOrDefinition.profile.isPresent()) { - console.println(CliMessages.MESSAGES.installingProfile(featurePackOrDefinition.profile.get())); - } else if (featurePackOrDefinition.definition.isPresent()) { - console.println(CliMessages.MESSAGES.installingDefinition(featurePackOrDefinition.definition.get())); - } + try (ProvisioningAction provisioningAction = actionFactory.install(directory.toAbsolutePath(), mavenOptions, + gpgKeystore, console)) { + if (featurePackOrDefinition.fpl.isPresent()) { + console.println(CliMessages.MESSAGES.installingFpl(featurePackOrDefinition.fpl.get())); + } else if (featurePackOrDefinition.profile.isPresent()) { + console.println(CliMessages.MESSAGES.installingProfile(featurePackOrDefinition.profile.get())); + } else if (featurePackOrDefinition.definition.isPresent()) { + console.println(CliMessages.MESSAGES.installingDefinition(featurePackOrDefinition.definition.get())); + } - final List effectiveChannels = TemporaryRepositoriesHandler.overrideRepositories(channels, shadowRepositories); - console.println(CliMessages.MESSAGES.usingChannels()); - final ChannelPrinter channelPrinter = new ChannelPrinter(console); - for (Channel channel : effectiveChannels) { - channelPrinter.print(channel); - } - console.println(""); + final List effectiveChannels = TemporaryRepositoriesHandler.overrideRepositories(channels, shadowRepositories); + console.println(CliMessages.MESSAGES.usingChannels()); + final ChannelPrinter channelPrinter = new ChannelPrinter(console); + for (Channel channel : effectiveChannels) { + channelPrinter.print(channel); + } - final List pendingLicenses = provisioningAction.getPendingLicenses(provisioningConfig, - effectiveChannels); - if (!pendingLicenses.isEmpty()) { - new LicensePrinter(console).print(pendingLicenses); console.println(""); - if (acceptAgreements) { - console.println(CliMessages.MESSAGES.agreementSkipped(CliConstants.ACCEPT_AGREEMENTS)); + + final List pendingLicenses = provisioningAction.getPendingLicenses(provisioningConfig, + effectiveChannels); + if (!pendingLicenses.isEmpty()) { + new LicensePrinter(console).print(pendingLicenses); console.println(""); - } else { - if (!console.confirm(CliMessages.MESSAGES.acceptAgreements(), "", CliMessages.MESSAGES.installationCancelled())) { - return ReturnCodes.PROCESSING_ERROR; + if (acceptAgreements) { + console.println(CliMessages.MESSAGES.agreementSkipped(CliConstants.ACCEPT_AGREEMENTS)); + console.println(""); + } else { + if (!console.confirm(CliMessages.MESSAGES.acceptAgreements(), "", CliMessages.MESSAGES.installationCancelled())) { + return ReturnCodes.PROCESSING_ERROR; + } } } - } - provisioningAction.provision(provisioningConfig, channels, shadowRepositories); + provisioningAction.provision(provisioningConfig, channels, shadowRepositories); - console.println(""); - console.println(CliMessages.MESSAGES.installComplete(directory)); + console.println(""); + console.println(CliMessages.MESSAGES.installComplete(directory)); - final float totalTime = (System.currentTimeMillis() - startTime) / 1000f; - console.println(CliMessages.MESSAGES.operationCompleted(totalTime)); + final float totalTime = (System.currentTimeMillis() - startTime) / 1000f; + console.println(CliMessages.MESSAGES.operationCompleted(totalTime)); - return ReturnCodes.SUCCESS; + return ReturnCodes.SUCCESS; + } } } diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/PrintLicensesCommand.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/PrintLicensesCommand.java index 2d17be41d..de58f9765 100644 --- a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/PrintLicensesCommand.java +++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/PrintLicensesCommand.java @@ -57,20 +57,21 @@ public Integer call() throws Exception { final GalleonProvisioningConfig provisioningConfig = provisioningDefinition.toProvisioningConfig(); final List channels = ChannelUtils.resolveChannels(provisioningDefinition, mavenOptions); - final ProvisioningAction provisioningAction = actionFactory.install(tempDirectory.toAbsolutePath(), - mavenOptions, console); + try (ProvisioningAction provisioningAction = actionFactory.install(tempDirectory.toAbsolutePath(), + mavenOptions, null, console)) { - final List pendingLicenses = provisioningAction.getPendingLicenses(provisioningConfig, channels); - if (!pendingLicenses.isEmpty()) { - console.println(""); - console.println(CliMessages.MESSAGES.listAgreementsHeader()); - console.println(""); - new LicensePrinter(console).print(pendingLicenses); - } else { - console.println(""); - console.println(CliMessages.MESSAGES.noAgreementsNeeded()); + final List pendingLicenses = provisioningAction.getPendingLicenses(provisioningConfig, channels); + if (!pendingLicenses.isEmpty()) { + console.println(""); + console.println(CliMessages.MESSAGES.listAgreementsHeader()); + console.println(""); + new LicensePrinter(console).print(pendingLicenses); + } else { + console.println(""); + console.println(CliMessages.MESSAGES.noAgreementsNeeded()); + } + return ReturnCodes.SUCCESS; } - return ReturnCodes.SUCCESS; } finally { FileUtils.deleteQuietly(tempDirectory.toFile()); } diff --git a/prospero-cli/src/main/resources/UsageMessages.properties b/prospero-cli/src/main/resources/UsageMessages.properties index 873ae59fa..f46389dc9 100644 --- a/prospero-cli/src/main/resources/UsageMessages.properties +++ b/prospero-cli/src/main/resources/UsageMessages.properties @@ -197,6 +197,7 @@ certificate-file = Path to the file containing armored public key GPG certificat key-id = The key ID of the public key to be removed. The key needs to be in a hexadecimal form. revoke-certificate = Path to the file containing armored revocation certificate of a public key. gpg-check = Require all artifacts from this channel to be GPG verified. +gpg-keystore = Path to a GPG keystore that should be used to verify downloaded artifacts. ${prospero.dist.name}.update.prepare.candidate-dir = Target directory where the candidate server will be provisioned. The existing server is not updated. ${prospero.dist.name}.update.subscribe.product = Specify the product name. This must be a known feature pack supported by ${prospero.dist.name}. diff --git a/prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/InstallCommandTest.java b/prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/InstallCommandTest.java index d52e84226..c1da9d505 100644 --- a/prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/InstallCommandTest.java +++ b/prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/InstallCommandTest.java @@ -60,6 +60,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @RunWith(MockitoJUnitRunner.class) @@ -93,7 +94,7 @@ protected ActionFactory createActionFactory() { @Before public void setUp() throws Exception { super.setUp(); - when(actionFactory.install(any(), any(), any())).thenReturn(provisionAction); + when(actionFactory.install(any(), any(), any(), any())).thenReturn(provisionAction); } @Test @@ -446,9 +447,63 @@ public void multipleManifestsAreTranslatedToMultipleChannels() throws Exception ); } + @Test + public void setGpgIsPassedToAction() throws Exception { + int exitCode = commandLine.execute(CliConstants.Commands.INSTALL, CliConstants.DIR, "test", + CliConstants.FPL, "org.wildfly:wildfly-ee-galleon-pack", + CliConstants.CHANNEL_MANIFEST, "test:test-manifest", + CliConstants.REPOSITORIES, "test::http://test.te", + CliConstants.GPG_CHECK); + assertEquals(ReturnCodes.SUCCESS, exitCode); + Mockito.verify(provisionAction).provision(any(), channelCaptor.capture(), any()); + assertThat(channelCaptor.getValue()) + .map(Channel::isGpgCheck) + .containsOnly(true); + } + + @Test + public void keystoreToBeUsedIsPassedToAction() throws Exception { + final File keystoreFile = temporaryFolder.newFile("keystore.gpg"); + int exitCode = commandLine.execute(CliConstants.Commands.INSTALL, CliConstants.DIR, "test", + CliConstants.FPL, "org.wildfly:wildfly-ee-galleon-pack", + CliConstants.CHANNEL_MANIFEST, "test:test-manifest", + CliConstants.REPOSITORIES, "test::http://test.te", + CliConstants.GPG_KEYSTORE, keystoreFile.getAbsolutePath()); + assertEquals(ReturnCodes.SUCCESS, exitCode); + Mockito.verify(actionFactory).install(any(), any(), eq(keystoreFile.toPath()), any()); + } + + @Test + public void validateKeystoreExists() throws Exception { + final Path keystoreFile = temporaryFolder.getRoot().toPath().resolve("idontexist.gpg"); + int exitCode = commandLine.execute(CliConstants.Commands.INSTALL, CliConstants.DIR, "test", + CliConstants.FPL, "org.wildfly:wildfly-ee-galleon-pack", + CliConstants.CHANNEL_MANIFEST, "test:test-manifest", + CliConstants.REPOSITORIES, "test::http://test.te", + CliConstants.GPG_KEYSTORE, keystoreFile.toString()); + assertEquals(ReturnCodes.INVALID_ARGUMENTS, exitCode); + assertThat(getErrorOutput()) + .contains(CliMessages.MESSAGES.certificateNonExistingFilePath(keystoreFile).getMessage()); + } + + @Test + public void validateKeystoreIsAValidKeystore() throws Exception { + final Path keystoreFile = temporaryFolder.newFile("keystore.gpg").toPath(); + Files.writeString(keystoreFile, "some rubbish"); + + int exitCode = commandLine.execute(CliConstants.Commands.INSTALL, CliConstants.DIR, "test", + CliConstants.FPL, "org.wildfly:wildfly-ee-galleon-pack", + CliConstants.CHANNEL_MANIFEST, "test:test-manifest", + CliConstants.REPOSITORIES, "test::http://test.te", + CliConstants.GPG_KEYSTORE, keystoreFile.toString()); + assertEquals(ReturnCodes.INVALID_ARGUMENTS, exitCode); + assertThat(getErrorOutput()) + .contains(CliMessages.MESSAGES.unableToReadKeyring(keystoreFile, new Exception("")).getMessage()); + } + @Override protected MavenOptions getCapturedMavenOptions() throws Exception { - Mockito.verify(actionFactory).install(any(), mavenOptions.capture(), any()); + Mockito.verify(actionFactory).install(any(), mavenOptions.capture(), any(), any()); return mavenOptions.getValue(); } diff --git a/prospero-common/src/main/java/org/wildfly/prospero/actions/ProvisioningAction.java b/prospero-common/src/main/java/org/wildfly/prospero/actions/ProvisioningAction.java index 468e9e550..1295cd169 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/actions/ProvisioningAction.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/actions/ProvisioningAction.java @@ -68,7 +68,7 @@ import org.jboss.galleon.ProvisioningException; import org.jboss.galleon.api.config.GalleonProvisioningConfig; -public class ProvisioningAction { +public class ProvisioningAction implements AutoCloseable { private static final String CHANNEL_NAME_PREFIX = "channel-"; private final MavenSessionManager mavenSessionManager; @@ -76,14 +76,28 @@ public class ProvisioningAction { private final Console console; private final LicenseManager licenseManager; private final MavenOptions mvnOptions; - private Path tempKeyringPath; + private final Path keyringPath; + private final boolean deleteKeyringOnExit; - public ProvisioningAction(Path installDir, MavenOptions mvnOptions, Console console) throws ProvisioningException { + public ProvisioningAction(Path installDir, MavenOptions mvnOptions, Console console) + throws ProvisioningException { + this(installDir, mvnOptions, null, console); + } + + public ProvisioningAction(Path installDir, MavenOptions mvnOptions, Path keystorePath, Console console) + throws ProvisioningException { this.installDir = InstallFolderUtils.toRealPath(installDir); this.console = console; this.mvnOptions = mvnOptions; this.mavenSessionManager = new MavenSessionManager(mvnOptions); this.licenseManager = new LicenseManager(); + // if the keystorePath is empty, we need to generate a temporary file and delete it on exit + this.deleteKeyringOnExit = keystorePath == null; + try { + this.keyringPath = keystorePath == null ? Files.createTempFile("keystore", "gpg") : keystorePath; + } catch (IOException e) { + throw new ProvisioningException(e); + } verifyInstallDir(installDir); } @@ -132,7 +146,7 @@ public void provision(GalleonProvisioningConfig provisioningConfig, List getPendingLicenses(GalleonProvisioningConfig provisioningConfig, List channels) throws OperationException, ProvisioningException { + public List getPendingLicenses(GalleonProvisioningConfig provisioningConfig, List channels) + throws OperationException, ProvisioningException { Objects.requireNonNull(provisioningConfig); Objects.requireNonNull(channels); final GalleonFeaturePackAnalyzer exporter = new GalleonFeaturePackAnalyzer(channels, mavenSessionManager, - console, getKeyringPath()); + console, keyringPath); return getPendingLicenses(provisioningConfig, exporter); } @@ -317,4 +318,11 @@ private ArtifactResolutionException wrapAetherException(org.eclipse.aether.resol return new ArtifactResolutionException(ProsperoLogger.ROOT_LOGGER.unableToResolve(), e, missingArtifacts, repositories, mavenSessionManager.isOffline()); } + + @Override + public void close() { + if (deleteKeyringOnExit && keyringPath != null && Files.exists(keyringPath)) { + FileUtils.deleteQuietly(keyringPath.toFile()); + } + } } diff --git a/prospero-common/src/main/java/org/wildfly/prospero/api/ProvisioningDefinition.java b/prospero-common/src/main/java/org/wildfly/prospero/api/ProvisioningDefinition.java index 128e35b27..88c0e4133 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/api/ProvisioningDefinition.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/api/ProvisioningDefinition.java @@ -28,6 +28,7 @@ import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.xml.stream.XMLStreamException; @@ -93,6 +94,8 @@ public class ProvisioningDefinition { private final String configStabilityLevel; private final String packageStabilityLevel; + private final Boolean requireGpgCheck; + private ProvisioningDefinition(Builder builder) throws NoChannelException { this.overrideRepositories.addAll(builder.overrideRepositories); this.channelCoordinates.addAll(builder.channelCoordinates); @@ -100,6 +103,8 @@ private ProvisioningDefinition(Builder builder) throws NoChannelException { this.stabilityLevel = builder.stabilityLevel; this.configStabilityLevel = builder.configStabilityLevel; this.packageStabilityLevel = builder.packageStabilityLevel; + this.requireGpgCheck = builder.requireGpgCheck; + if (StringUtils.isNotEmpty(stabilityLevel) && (StringUtils.isNotEmpty(packageStabilityLevel) || StringUtils.isNotEmpty(configStabilityLevel))) { throw new IllegalArgumentException("Provisioning option stabilityLevel cannot be used with packageStabilityLevel or configStabilityLevel"); @@ -222,6 +227,17 @@ public List resolveChannels(VersionResolverFactory versionResolverFacto channels = TemporaryRepositoriesHandler.overrideRepositories(channels, overrideRepositories); + Stream builderStream = channels.stream() + .map(Channel.Builder::new); + + if (requireGpgCheck != null) { + builderStream = builderStream + .map(b -> b.setGpgCheck(requireGpgCheck)); + } + + channels = builderStream.map(Channel.Builder::build) + .collect(Collectors.toList()); + validateResolvedChannels(channels); return channels; } catch (InvalidChannelMetadataException e) { @@ -283,6 +299,7 @@ public static class Builder { private String stabilityLevel; private String packageStabilityLevel; private String configStabilityLevel; + private Boolean requireGpgCheck; public ProvisioningDefinition build() throws MetadataException, NoChannelException { return new ProvisioningDefinition(this); @@ -349,5 +366,10 @@ public Builder setConfigStabilityLevel(String configStabilityLevel) { this.configStabilityLevel = configStabilityLevel; return this; } + + public Builder setRequireGpgCheck(Boolean requireGpgCheck) { + this.requireGpgCheck = requireGpgCheck; + return this; + } } } diff --git a/prospero-common/src/main/java/org/wildfly/prospero/signatures/CachedPGPKeystore.java b/prospero-common/src/main/java/org/wildfly/prospero/signatures/CachedPGPKeystore.java index 1f3e57ff2..ff18c536a 100644 --- a/prospero-common/src/main/java/org/wildfly/prospero/signatures/CachedPGPKeystore.java +++ b/prospero-common/src/main/java/org/wildfly/prospero/signatures/CachedPGPKeystore.java @@ -31,6 +31,7 @@ import java.util.List; import java.util.Locale; +import org.bouncycastle.bcpg.ArmoredInputStream; import org.bouncycastle.openpgp.PGPException; import org.bouncycastle.openpgp.PGPPublicKey; import org.bouncycastle.openpgp.PGPPublicKeyRing; diff --git a/prospero-common/src/test/java/org/wildfly/prospero/api/ProvisioningDefinitionTest.java b/prospero-common/src/test/java/org/wildfly/prospero/api/ProvisioningDefinitionTest.java index 89384087c..1fbc18386 100644 --- a/prospero-common/src/test/java/org/wildfly/prospero/api/ProvisioningDefinitionTest.java +++ b/prospero-common/src/test/java/org/wildfly/prospero/api/ProvisioningDefinitionTest.java @@ -337,6 +337,43 @@ public void setConfigAndPackageStabilityLevelsWithFpl() throws Exception { entry(Constants.CONFIG_STABILITY_LEVEL, Constants.STABILITY_COMMUNITY)); } + @Test + public void keepsGpgCheckIfNotSetInBuilderFromProfile() throws Exception { + final ProvisioningDefinition definition = new ProvisioningDefinition.Builder() + .setProfile("with-gpg-check") + .build(); + + assertThat(definition.resolveChannels(null)) + .map(Channel::isGpgCheck) + .containsOnly(true); + } + + @Test + public void disableGpgCheckFromProfile() throws Exception { + final ProvisioningDefinition definition = new ProvisioningDefinition.Builder() + .setProfile("with-gpg-check") + .setRequireGpgCheck(false) + .build(); + + assertThat(definition.resolveChannels(null)) + .map(Channel::isGpgCheck) + .containsOnly(false); + } + + @Test + public void enableGpgCheckForFpl() throws Exception { + final ProvisioningDefinition definition = new ProvisioningDefinition.Builder() + .setFpl("one:two") + .setManifest("manifest") + .setOverrideRepositories(List.of(new Repository("test", "test"))) + .setRequireGpgCheck(true) + .build(); + + assertThat(definition.resolveChannels(null)) + .map(Channel::isGpgCheck) + .containsOnly(true); + } + private void verifyFeaturePackLocation(ProvisioningDefinition definition) throws ProvisioningException, XMLStreamException { assertNull(definition.getFpl()); GalleonProvisioningConfig galleonConfig = GalleonUtils.loadProvisioningConfig(definition.getDefinition()); diff --git a/prospero-common/src/test/resources/prospero-installation-profiles.yaml b/prospero-common/src/test/resources/prospero-installation-profiles.yaml index 5e928d933..f138b8bb4 100644 --- a/prospero-common/src/test/resources/prospero-installation-profiles.yaml +++ b/prospero-common/src/test/resources/prospero-installation-profiles.yaml @@ -37,3 +37,15 @@ maven: groupId: "test" artifactId: "one" +- name: "with-gpg-check" + galleonConfiguration: "classpath:galleon-provisioning.xml" + channels: + - schemaVersion: "2.1.0" + repositories: + - id: "central" + url: "https://repo1.maven.org/maven2/" + manifest: + maven: + groupId: "test" + artifactId: "one" + gpg-check: true \ No newline at end of file