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 bc49dccd5..46bec3054 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 c198905ce..c27f70e31 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/integration-tests/pom.xml b/integration-tests/pom.xml
index 983292ac2..220e038a0 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/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/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/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/it/signatures/InstallationTestCase.java b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/InstallationTestCase.java
new file mode 100644
index 000000000..9ef58fc06
--- /dev/null
+++ b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/InstallationTestCase.java
@@ -0,0 +1,239 @@
+/*
+ * 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.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;
+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.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;
+import org.wildfly.prospero.test.TestLocalRepository;
+
+public class InstallationTestCase {
+
+ protected static final String COMMONS_IO_VERSION = BuildProperties.getProperty("version.commons-io");
+ protected static final String GALLEON_PLUGINS_VERSION = BuildProperties.getProperty("version.org.wildfly.galleon-plugins");
+ @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", COMMONS_IO_VERSION)
+ .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", COMMONS_IO_VERSION);
+ 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"));
+ }
+
+ @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", COMMONS_IO_VERSION);
+ testInstallation.verifyInstallationMetadataPresent();
+ CertificateUtils.assertKeystoreContains(serverPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg"), pgpValidKeys.getPublicKey().getKeyID());
+ }
+
+ private void prepareRequiredArtifacts(TestLocalRepository localRepository) throws Exception {
+
+ localRepository.resolveAndDeploy(new DefaultArtifact("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", "jar", GALLEON_PLUGINS_VERSION));
+ localRepository.resolveAndDeploy(new DefaultArtifact("org.wildfly.galleon-plugins", "wildfly-config-gen", "jar", GALLEON_PLUGINS_VERSION));
+ localRepository.resolveAndDeploy(new DefaultArtifact("commons-io", "commons-io", "jar", COMMONS_IO_VERSION));
+
+ 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", GALLEON_PLUGINS_VERSION),
+ new Stream("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", GALLEON_PLUGINS_VERSION),
+ new Stream("commons-io", "commons-io", COMMONS_IO_VERSION),
+ new Stream("org.test", "pack-one", "1.0.0")
+ )));
+ }
+
+}
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/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/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/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/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/VerifyServerOriginTestCase.java b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/VerifyServerOriginTestCase.java
new file mode 100644
index 000000000..dd677b617
--- /dev/null
+++ b/integration-tests/src/test/java/org/wildfly/prospero/it/signatures/VerifyServerOriginTestCase.java
@@ -0,0 +1,359 @@
+package org.wildfly.prospero.it.signatures;
+
+import java.io.File;
+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.assertj.core.api.Assertions;
+import org.bouncycastle.openpgp.PGPSecretKeyRing;
+import org.eclipse.aether.artifact.DefaultArtifact;
+import org.jboss.galleon.ProvisioningException;
+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.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.prospero.actions.CertificateAction;
+import org.wildfly.prospero.actions.VerificationResult;
+import org.wildfly.prospero.api.MavenOptions;
+import org.wildfly.prospero.api.ProvisioningProgressEvent;
+import org.wildfly.prospero.api.exceptions.OperationException;
+import org.wildfly.prospero.galleon.ArtifactCache;
+import org.wildfly.prospero.signatures.PGPKeyId;
+import org.wildfly.prospero.signatures.PGPPublicKeyInfo;
+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 VerifyServerOriginTestCase {
+
+ protected static final String COMMONS_IO_VERSION = BuildProperties.getProperty("version.commons-io");
+ protected static final String GALLEON_PLUGINS_VERSION = BuildProperties.getProperty("version.org.wildfly.galleon-plugins");
+ @Rule
+ public TemporaryFolder temp = new TemporaryFolder();
+ @ClassRule
+ public static TemporaryFolder classTemp = new TemporaryFolder();
+ private TestLocalRepository testLocalRepository;
+ private TestInstallation testInstallation;
+ private static Path serverPath;
+ private static PGPSecretKeyRing pgpValidKeys;
+ private static File certFile;
+ private static Channel testChannel;
+ private static PGPSecretKeyRing pgpInValidKeys;
+ private static File baseServer;
+ private static File repoPath;
+ private static File baseRepoPath;
+
+ @BeforeClass
+ public static void classSetUp() throws Exception {
+ baseRepoPath = classTemp.newFolder("base-local-repo");
+ repoPath = classTemp.newFolder("local-repo");
+ pgpValidKeys = CertificateUtils.generatePrivateKey();
+ pgpInValidKeys = CertificateUtils.generatePrivateKey();
+ certFile = CertificateUtils.exportPublicCertificate(pgpValidKeys, classTemp.newFile("public.crt"));
+
+ final TestLocalRepository localRepository = new TestLocalRepository(baseRepoPath.toPath(),
+ List.of(new URL("https://repo1.maven.org/maven2")));
+
+ prepareRequiredArtifacts(localRepository);
+
+ localRepository.deploy(TestInstallation.fpBuilder("org.test:pack-one:1.0.0")
+ .addModule("commons-io", "commons-io", COMMONS_IO_VERSION)
+ .addFile("test.txt", "Text 1.0.0")
+ .build());
+ localRepository.signAllArtifacts(pgpValidKeys);
+
+ FileUtils.copyDirectory(baseRepoPath, repoPath);
+
+ baseServer = classTemp.newFolder("base-server");
+ final TestInstallation testInstallation = new TestInstallation(baseServer.toPath());
+ testChannel = new Channel.Builder()
+ .setName("test-channel")
+ .setGpgCheck(true)
+ .addGpgUrl(certFile.toURI().toString())
+ .addRepository("local-repo", repoPath.toURI().toString())
+ .setManifestCoordinate(new ChannelManifestCoordinate("org.test", "test-channel"))
+ .build();
+
+
+ // install server
+ testInstallation.install("org.test:pack-one:1.0.0", List.of(testChannel));
+ }
+
+ @Before
+ public void setUp() throws Exception {
+ serverPath = temp.newFolder("test-server").toPath();
+ FileUtils.copyDirectory(baseServer, serverPath.toFile());
+ testInstallation = new TestInstallation(serverPath);
+
+ FileUtils.copyDirectory(baseRepoPath, repoPath);
+ testLocalRepository = new TestLocalRepository(repoPath.toPath(),
+ List.of(new URL("https://repo1.maven.org/maven2")));
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ FileUtils.deleteQuietly(repoPath);
+ }
+
+ @Test
+ public void unsignedJarInTheServer_CausesError() throws Exception {
+ // remove one of the signatures
+ testLocalRepository.removeSignature("commons-io", "commons-io", COMMONS_IO_VERSION);
+
+ // run verification
+ final VerificationResult verify = verifyServer();
+
+ // expect error with failing jar listed
+ Assertions.assertThat(verify.getUnsignedBinary())
+ .containsExactlyInAnyOrder(new VerificationResult.InvalidBinary(
+ serverPath.relativize(testInstallation.getModulePath("commons-io", "commons-io").resolve("commons-io-" + COMMONS_IO_VERSION + ".jar")),
+ "commons-io:commons-io:jar:" + COMMONS_IO_VERSION, SignatureResult.Result.NO_SIGNATURE
+ ));
+ Assertions.assertThat(verify.getModifiedFiles()).isEmpty();
+ }
+
+ @Test
+ public void multipleUnsignedBinariesAreReported() throws Exception {
+ // remove both of the signatures
+ testLocalRepository.removeSignature("commons-io", "commons-io", COMMONS_IO_VERSION);
+ testLocalRepository.removeSignature("org.wildfly.galleon-plugins", "wildfly-config-gen", GALLEON_PLUGINS_VERSION);
+
+ // run verification
+ final VerificationResult verify = verifyServer();
+
+ // expect error with failing jar listed
+ Assertions.assertThat(verify.getUnsignedBinary())
+ .containsExactlyInAnyOrder(
+ new VerificationResult.InvalidBinary(
+ serverPath.relativize(testInstallation.getModulePath("commons-io", "commons-io")
+ .resolve("commons-io-" + COMMONS_IO_VERSION + ".jar")),
+ "commons-io:commons-io:jar:" + COMMONS_IO_VERSION, SignatureResult.Result.NO_SIGNATURE),
+ new VerificationResult.InvalidBinary(
+ ArtifactCache.CACHE_FOLDER.resolve("wildfly-config-gen-" + GALLEON_PLUGINS_VERSION + ".jar"),
+ "org.wildfly.galleon-plugins:wildfly-config-gen:jar:" + GALLEON_PLUGINS_VERSION, SignatureResult.Result.NO_SIGNATURE)
+ );
+ Assertions.assertThat(verify.getModifiedFiles()).isEmpty();
+ }
+
+ @Test
+ public void ignoreUpdatesWhenCheckingOrigin() throws Exception {
+ // remove one of the signatures
+ testLocalRepository.deployMockUpdate("commons-io", "commons-io", COMMONS_IO_VERSION, ".SP1");
+ testLocalRepository.removeSignature("commons-io", "commons-io", COMMONS_IO_VERSION);
+
+ // run verification
+ final VerificationResult verify = verifyServer();
+
+ // expect error with failing jar listed
+ Assertions.assertThat(verify.getUnsignedBinary())
+ .containsExactlyInAnyOrder(
+ new VerificationResult.InvalidBinary(
+ serverPath.relativize(testInstallation.getModulePath("commons-io", "commons-io")
+ .resolve("commons-io-" + COMMONS_IO_VERSION + ".jar")),
+ "commons-io:commons-io:jar:" + COMMONS_IO_VERSION, SignatureResult.Result.NO_SIGNATURE)
+ );
+ Assertions.assertThat(verify.getModifiedFiles()).isEmpty();
+ }
+
+ @Test
+ public void invalidSignatureInTheServer_CausesError() throws Exception {
+ // regenerate signature with untrusted key
+ testLocalRepository.removeSignature("commons-io", "commons-io", COMMONS_IO_VERSION);
+ testLocalRepository.signAllArtifacts(pgpInValidKeys);
+
+ // run verification
+ final VerificationResult verify = verifyServer();
+
+ // expect error with failing jar listed
+ Assertions.assertThat(verify.getUnsignedBinary())
+ .containsExactlyInAnyOrder(new VerificationResult.InvalidBinary(
+ serverPath.relativize(testInstallation.getModulePath("commons-io", "commons-io").resolve("commons-io-" + COMMONS_IO_VERSION + ".jar")),
+ "commons-io:commons-io:jar:" + COMMONS_IO_VERSION, SignatureResult.Result.NO_MATCHING_CERT,
+ new PGPKeyId(pgpInValidKeys.getPublicKey().getKeyID()).getHexKeyID()
+ ));
+ Assertions.assertThat(verify.getModifiedFiles()).isEmpty();
+ }
+
+ @Test
+ public void corruptedJarInTheServer_CausesError() throws Exception {
+ final Path moduleJarPath = testInstallation.getModulePath("commons-io", "commons-io").resolve("commons-io-" + COMMONS_IO_VERSION + ".jar");
+ Files.writeString(moduleJarPath, "I'm corrupted");
+
+ // run verification
+ final VerificationResult verify = verifyServer();
+
+ // expect error with failing jar listed
+ Assertions.assertThat(verify.getUnsignedBinary())
+ .containsExactlyInAnyOrder(new VerificationResult.InvalidBinary(
+ serverPath.relativize(testInstallation.getModulePath("commons-io", "commons-io").resolve("commons-io-" + COMMONS_IO_VERSION + ".jar")),
+ "commons-io:commons-io:jar:" + COMMONS_IO_VERSION, SignatureResult.Result.INVALID,
+ new PGPKeyId(pgpValidKeys.getPublicKey().getKeyID()).getHexKeyID()
+ ));
+ Assertions.assertThat(verify.getModifiedFiles()).isEmpty();
+ }
+
+ @Test
+ public void serverWithoutCacheCanBeValidated() throws Exception {
+ FileUtils.deleteQuietly(serverPath.resolve(ArtifactCache.CACHE_FOLDER).toFile());
+
+ // run verification
+ final VerificationResult verify = verifyServer();
+
+ // expect error with failing jar listed
+ Assertions.assertThat(verify.getUnsignedBinary())
+ .isEmpty();
+ Assertions.assertThat(verify.getModifiedFiles()).isEmpty();
+ }
+
+ @Test
+ public void corruptedJarInTheServerCache_CausesError() throws Exception {
+ final Path cachedFpZip = serverPath.resolve(ArtifactCache.CACHE_FOLDER).resolve("pack-one-1.0.0.zip");
+ Assertions.assertThat(cachedFpZip).exists();
+ Files.writeString(cachedFpZip, "I'm corrupted");
+
+ // run verification
+ final VerificationResult verify = verifyServer();
+
+ // expect error with failing jar listed
+ Assertions.assertThat(verify.getUnsignedBinary())
+ .containsExactlyInAnyOrder(new VerificationResult.InvalidBinary(
+ serverPath.relativize(cachedFpZip),
+ "org.test:pack-one:zip:1.0.0", SignatureResult.Result.INVALID,
+ new PGPKeyId(pgpValidKeys.getPublicKey().getKeyID()).getHexKeyID()
+ ));
+ Assertions.assertThat(verify.getModifiedFiles()).isEmpty();
+ }
+
+ @Test
+ public void missingJarInTheServer_IsIgnored() throws Exception {
+ FileUtils.deleteQuietly(testInstallation
+ .getModulePath("commons-io", "commons-io").resolve("commons-io-" + COMMONS_IO_VERSION + ".jar")
+ .toFile());
+
+ // run verification
+ final VerificationResult verify = verifyServer();
+
+ // expect error with failing jar listed
+ Assertions.assertThat(verify.getUnsignedBinary())
+ .isEmpty();
+ Assertions.assertThat(verify.getModifiedFiles()).isEmpty();
+ }
+
+ @Test
+ public void unexpectedJarInTheServer_CausesError() throws Exception {
+ final Path addedBinary = testInstallation
+ .getModulePath("commons-io", "commons-io").resolve("commons-io-" + COMMONS_IO_VERSION + "-SP1.jar");
+ FileUtils.copyFile(testInstallation
+ .getModulePath("commons-io", "commons-io").resolve("commons-io-" + COMMONS_IO_VERSION + ".jar")
+ .toFile(),
+ addedBinary
+ .toFile());
+
+ // run verification
+ final VerificationResult verify = verifyServer();
+
+ // expect error with failing jar listed
+ Assertions.assertThat(verify.getUnsignedBinary())
+ .containsExactlyInAnyOrder(new VerificationResult.InvalidBinary(
+ serverPath.relativize(addedBinary),
+ null, SignatureResult.Result.NO_SIGNATURE
+ ));
+ Assertions.assertThat(verify.getModifiedFiles()).isEmpty();
+ }
+
+ @Test
+ public void changedFileInTheServer_CausesWarning() throws Exception {
+ Assertions.assertThat(serverPath.resolve("test.txt")).exists();
+ Files.writeString(serverPath.resolve("test.txt"), "User change");
+
+ // run verification
+ final VerificationResult verify = verifyServer();
+
+ // expect error with failing jar listed
+ Assertions.assertThat(verify.getModifiedFiles())
+ .containsExactlyInAnyOrder(
+ Path.of("test.txt")
+ );
+ }
+
+ @Test
+ public void listTrustedCertificatesUsedByTheServer() throws Exception {
+ // run verification
+ final VerificationResult verify = verifyServer();
+
+ // expect error with failing jar listed
+ Assertions.assertThat(verify.getTrustedCertificates())
+ .containsExactlyInAnyOrder(PGPPublicKeyInfo.parse(pgpValidKeys.getPublicKey()));
+ }
+
+ private static VerificationResult verifyServer() throws ProvisioningException, OperationException {
+ try (CertificateAction certificateAction = new CertificateAction(serverPath)) {
+ return certificateAction.verifyServerOrigin(new NoopVerificationListener(), MavenOptions.OFFLINE_NO_CACHE);
+ }
+ }
+
+ private static void prepareRequiredArtifacts(TestLocalRepository localRepository) throws Exception {
+
+ localRepository.resolveAndDeploy(new DefaultArtifact("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", "jar", GALLEON_PLUGINS_VERSION));
+ localRepository.resolveAndDeploy(new DefaultArtifact("org.wildfly.galleon-plugins", "wildfly-config-gen", "jar", GALLEON_PLUGINS_VERSION));
+ localRepository.resolveAndDeploy(new DefaultArtifact("commons-io", "commons-io", "jar", COMMONS_IO_VERSION));
+
+ 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", GALLEON_PLUGINS_VERSION),
+ new Stream("org.wildfly.galleon-plugins", "wildfly-galleon-plugins", GALLEON_PLUGINS_VERSION),
+ new Stream("commons-io", "commons-io", COMMONS_IO_VERSION),
+ new Stream("org.test", "pack-one", "1.0.0")
+ )));
+ }
+
+ private static class NoopVerificationListener implements CertificateAction.VerificationListener {
+ @Override
+ public void progressUpdate(ProvisioningProgressEvent update) {
+
+ }
+
+ @Override
+ public void provisionReferenceServerStarted() {
+
+ }
+
+ @Override
+ public void provisionReferenceServerFinished() {
+
+ }
+
+ @Override
+ public void validatingComponentsStarted() {
+
+ }
+
+ @Override
+ public void validatingComponentsFinished() {
+
+ }
+
+ @Override
+ public void checkingModifiedFilesStarted() {
+
+ }
+
+ @Override
+ public void checkingModifiedFilesFinished() {
+
+ }
+ }
+}
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/integration-tests/src/test/java/org/wildfly/prospero/test/TestInstallation.java b/integration-tests/src/test/java/org/wildfly/prospero/test/TestInstallation.java
index b1f78ad45..7b6747534 100644
--- a/integration-tests/src/test/java/org/wildfly/prospero/test/TestInstallation.java
+++ b/integration-tests/src/test/java/org/wildfly/prospero/test/TestInstallation.java
@@ -26,7 +26,9 @@
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -69,7 +71,7 @@ public static Builder fpBuilder(String name) {
}
public void verifyModuleJar(String groupId, String artifactId, String version) {
- final Path moduleRoot = serverRoot.resolve("modules").resolve(groupId.replace('.', '/') + "/" + artifactId + "/main");
+ final Path moduleRoot = getModulePath(groupId, artifactId);
assertThat(moduleRoot.resolve("module.xml"))
.exists()
@@ -78,6 +80,10 @@ public void verifyModuleJar(String groupId, String artifactId, String version) {
.exists();
}
+ public Path getModulePath(String groupId, String artifactId) {
+ return serverRoot.resolve("modules").resolve(groupId.replace('.', '/') + "/" + artifactId + "/main");
+ }
+
/**
* verifies that required files in .installation folder exist
*/
@@ -208,6 +214,7 @@ public void revertToOriginalState(Console console, List repositories) throw
*/
public static class Builder {
private List modules = new ArrayList<>();
+ private Map files = new HashMap<>();
private final String name;
private Builder(String name) {
@@ -223,6 +230,11 @@ public TestInstallation.Builder addModule(String groupId, String artifactId, Str
return this;
}
+ public Builder addFile(String file, String content) {
+ files.put(file, content);
+ return this;
+ }
+
public Artifact build() throws IOException, ProvisioningException {
final Path tempRoot = Files.createTempDirectory("fp-builder");
tempRoot.toFile().deleteOnExit();
@@ -252,7 +264,9 @@ public Artifact build() throws IOException, ProvisioningException {
module.getGroupId(), module.getArtifactId(), module.getVersion()));
}
-
+ for (String path : files.keySet()) {
+ packageBuilder.writeContent(path, files.get(path));
+ }
featurePackBuilder
.writeResources("wildfly/artifact-versions.properties",
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/pom.xml b/pom.xml
index 2e0c3963a..2bf85e890 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,16 +66,17 @@
2.1.5.Final
3.8.16.Final
1.7.0.Final
+ 1.6.1
7.2.0.Final
- 2.0.0.Beta1
+ 2.0.0.Beta1-SNAPSHOT
2.4.1.Final
- 1.3.0.Beta1
+ 1.3.0.Beta3-SNAPSHOT
5.14.1
2.0.7
2.2
4.13.2
3.6.0
- 1.2.0.Final
+ 1.2.1.Final-SNAPSHOT
3.10.1
33.0.2.Final
4.7.6
@@ -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
+
diff --git a/prospero-cli/pom.xml b/prospero-cli/pom.xml
index 30057a40a..db14808f5 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..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
@@ -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;
@@ -39,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
@@ -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..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;
@@ -208,9 +209,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 +246,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) {
@@ -262,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-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 daaf29292..50149d05d 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
@@ -722,4 +722,124 @@ default OperationException cancelledByConfilcts() {
bundle.getString("prospero.updates.apply.candidate.cancel_conflicts"),
CliConstants.NO_CONFLICTS_ONLY));
}
+
+ 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");
+ }
+
+ default ArgumentParsingException unableToReadKeyring(Path keyring, Exception cause) {
+ return new ArgumentParsingException(String.format("Unable to parse GPG keyring at %s: %s", keyring, cause.getMessage()), cause);
+ }
+
+ default String trustedCertificatesListHeader() {
+ return bundle.getString("prospero.verify-server.trusted_certificates.header");
+ }
+
+ default String verifiedComponentsOnly() {
+ return bundle.getString("prospero.verify-server.trusted_components_only.header");
+ }
+
+ default String unverifiedComponentsListHeader() {
+ return bundle.getString("prospero.verify-server.unverified_components.header");
+ }
+
+ default String modifiedFilesListHeader() {
+ return bundle.getString("prospero.verify-server.modified_files.header");
+ }
+
+ default String componentSignatureNotFound() {
+ return bundle.getString("prospero.verify-server.error.signature_not_found");
+ }
+
+ default String componentPublicKeyNotFound(String keyId) {
+ return String.format(bundle.getString("prospero.verify-server.error.untrusted_public_key"), keyId);
+ }
+
+ default String componentInvalidLocalFile() {
+ return bundle.getString("prospero.verify-server.error.invalid_local_file");
+ }
+
+ default String componentUnknownError(String error) {
+ return String.format(bundle.getString("prospero.verify-server.error.unknown_error"), error);
+ }
}
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-cli/src/main/java/org/wildfly/prospero/cli/commands/AbstractMavenCommand.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/AbstractMavenCommand.java
index 683926141..638b83d7a 100644
--- a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/AbstractMavenCommand.java
+++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/AbstractMavenCommand.java
@@ -31,7 +31,7 @@
public abstract class AbstractMavenCommand extends AbstractCommand {
@CommandLine.Option(names = CliConstants.DIR)
- Optional directory;
+ protected Optional directory;
@CommandLine.Option(names = CliConstants.REPOSITORIES, split = ",")
List temporaryRepositories = new ArrayList<>();
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 67baaaf89..bd826904a 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
@@ -31,11 +31,14 @@ private CliConstants() {
*/
public static final class Commands {
+ public static final String VALIDATE_SERVER = "validate-server";
+
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 +64,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 +79,11 @@ 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 GPG_KEYSTORE = "--gpg-keystore";
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 +97,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/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/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..343eb447a
--- /dev/null
+++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/CertificatesCommand.java
@@ -0,0 +1,21 @@
+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),
+ new ValidateServerOriginCommand(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/certificate/ValidateServerOriginCommand.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/ValidateServerOriginCommand.java
new file mode 100644
index 000000000..939ebfe14
--- /dev/null
+++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/ValidateServerOriginCommand.java
@@ -0,0 +1,103 @@
+/*
+ * 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 java.nio.file.Path;
+
+import org.wildfly.channel.spi.SignatureResult;
+import org.wildfly.prospero.actions.CertificateAction;
+import org.wildfly.prospero.actions.VerificationResult;
+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.AbstractMavenCommand;
+import org.wildfly.prospero.cli.commands.CliConstants;
+import org.wildfly.prospero.signatures.PGPPublicKeyInfo;
+import picocli.CommandLine;
+
+@CommandLine.Command(name = CliConstants.Commands.VALIDATE_SERVER)
+public class ValidateServerOriginCommand extends AbstractMavenCommand {
+
+ public ValidateServerOriginCommand(CliConsole console, ActionFactory actionFactory) {
+ super(console, actionFactory);
+ }
+
+ @Override
+ public Integer call() throws Exception {
+ final long startTime = System.currentTimeMillis();
+ final Path serverDir = determineInstallationDirectory(directory);
+
+ try (CertificateAction certificateAction = actionFactory.certificateAction(serverDir);
+ final VerificationConsole verificationConsole = new VerificationConsole(console)) {
+ final VerificationResult verificationResult = certificateAction.verifyServerOrigin(verificationConsole, parseMavenOptions());
+
+ console.println("");
+ if (!verificationResult.getTrustedCertificates().isEmpty()) {
+ console.println(CliMessages.MESSAGES.trustedCertificatesListHeader());
+ for (PGPPublicKeyInfo trustedCertificate : verificationResult.getTrustedCertificates()) {
+ console.printf(" * [%s] %s%n",
+ trustedCertificate.getKeyID().getHexKeyID(),
+ String.join(";", trustedCertificate.getIdentity())
+ );
+ }
+ console.println("");
+ }
+
+ if (verificationResult.getUnsignedBinary().isEmpty()) {
+ console.println(CliMessages.MESSAGES.verifiedComponentsOnly());
+ } else {
+ console.println(CliMessages.MESSAGES.unverifiedComponentsListHeader());
+ for (VerificationResult.InvalidBinary invalidBinary : verificationResult.getUnsignedBinary()) {
+ console.printf(" * %s : %s%n",
+ invalidBinary.getPath().toString(),
+ getErrorDescription(invalidBinary.getError(), invalidBinary.getKeyId())
+ );
+ }
+ console.println("");
+ }
+
+ if (!verificationResult.getModifiedFiles().isEmpty()) {
+ console.println(CliMessages.MESSAGES.modifiedFilesListHeader());
+ for (Path modifiedFile : verificationResult.getModifiedFiles()) {
+ console.printf(" * %s%n", modifiedFile.toString());
+ }
+ }
+ final float totalTime = (System.currentTimeMillis() - startTime) / 1000f;
+ console.println("");
+ console.println(CliMessages.MESSAGES.operationCompleted(totalTime));
+ if (verificationResult.getUnsignedBinary().isEmpty()) {
+ return ReturnCodes.SUCCESS;
+ } else {
+ return ReturnCodes.PROCESSING_ERROR;
+ }
+ }
+ }
+
+ private static String getErrorDescription(SignatureResult.Result error, String keyId) {
+ switch (error) {
+ case NO_SIGNATURE:
+ return CliMessages.MESSAGES.componentSignatureNotFound();
+ case NO_MATCHING_CERT:
+ return CliMessages.MESSAGES.componentPublicKeyNotFound(keyId);
+ case INVALID:
+ return CliMessages.MESSAGES.componentInvalidLocalFile();
+ default:
+ return CliMessages.MESSAGES.componentUnknownError(error.toString());
+ }
+ }
+}
diff --git a/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/VerificationConsole.java b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/VerificationConsole.java
new file mode 100644
index 000000000..2c15187e5
--- /dev/null
+++ b/prospero-cli/src/main/java/org/wildfly/prospero/cli/commands/certificate/VerificationConsole.java
@@ -0,0 +1,146 @@
+package org.wildfly.prospero.cli.commands.certificate;
+
+import static org.jboss.galleon.Constants.TRACK_CONFIGS;
+import static org.jboss.galleon.Constants.TRACK_LAYOUT_BUILD;
+import static org.jboss.galleon.Constants.TRACK_PACKAGES;
+import static org.wildfly.prospero.galleon.GalleonEnvironment.TRACK_JBEXAMPLES;
+import static org.wildfly.prospero.galleon.GalleonEnvironment.TRACK_JBMODULES;
+import static org.wildfly.prospero.galleon.GalleonEnvironment.TRACK_JB_ARTIFACTS_RESOLVE;
+import static org.wildfly.prospero.galleon.GalleonEnvironment.TRACK_RESOLVING_VERSIONS;
+
+import java.util.Timer;
+import java.util.TimerTask;
+
+import org.wildfly.prospero.actions.CertificateAction;
+import org.wildfly.prospero.api.ProvisioningProgressEvent;
+import org.wildfly.prospero.cli.CliConsole;
+import org.wildfly.prospero.cli.CliMessages;
+
+public class VerificationConsole implements CertificateAction.VerificationListener, AutoCloseable {
+
+ private final CliConsole console;
+ private TimerTask task;
+
+ public VerificationConsole(CliConsole console) {
+ this.console = console;
+ }
+
+ @Override
+ public void close() {
+ timer.cancel();
+ }
+
+ private final LinePrinter linePrinter = new LinePrinter();
+
+ private class LinePrinter {
+ private int lastLength = 0;
+
+ synchronized void print(String text) {
+ eraseLastLine();
+ lastLength = text.length();
+ console.printf(text);
+ }
+
+ private void eraseLastLine() {
+ console.printf("\r" + " ".repeat(Math.max(0, lastLength)) + "\r");
+ }
+ }
+
+ private final Timer timer = new Timer();
+
+ @Override
+ public void progressUpdate(ProvisioningProgressEvent update) {
+ if (update.getEventType() == ProvisioningProgressEvent.EventType.STARTING) {
+ final String currentState = toText(update.getStage());
+ task = new ProgressPrinterTask(currentState);
+ timer.scheduleAtFixedRate(task, 0, 100);
+
+ } else if (update.getEventType() == ProvisioningProgressEvent.EventType.COMPLETED) {
+ task.cancel();
+ final String line = toText(update.getStage()) + " DONE";
+ linePrinter.print(line);
+ }
+ }
+
+ @Override
+ public void provisionReferenceServerStarted() {
+ console.println("Generating reference server");
+ }
+
+ @Override
+ public void provisionReferenceServerFinished() {
+ task.cancel();
+ console.println("");
+ linePrinter.lastLength = 0;
+ }
+
+ @Override
+ public void validatingComponentsStarted() {
+ task = new ProgressPrinterTask("Validating component signatures");
+ timer.schedule(task, 0, 200);
+ }
+
+ @Override
+ public void validatingComponentsFinished() {
+ task.cancel();
+ linePrinter.print("Validating component signatures DONE");
+ console.println("");
+ linePrinter.lastLength = 0;
+ }
+
+ @Override
+ public void checkingModifiedFilesStarted() {
+ task = new ProgressPrinterTask("Checking for locally modified files");
+ timer.schedule(task, 0, 200);
+ }
+
+ @Override
+ public void checkingModifiedFilesFinished() {
+ task.cancel();
+ linePrinter.print("Checking for locally modified files DONE");
+ console.println("");
+ linePrinter.lastLength = 0;
+ }
+
+ private String toText(String stage) {
+ switch (stage) {
+ case TRACK_LAYOUT_BUILD:
+ return CliMessages.MESSAGES.resolvingFeaturePack();
+ case TRACK_PACKAGES:
+ return CliMessages.MESSAGES.installingPackages();
+ case TRACK_CONFIGS:
+ return CliMessages.MESSAGES.generatingConfiguration();
+ case TRACK_JBMODULES:
+ return CliMessages.MESSAGES.installingJBossModules();
+ case TRACK_JBEXAMPLES:
+ return CliMessages.MESSAGES.installingJBossExamples();
+ case TRACK_JB_ARTIFACTS_RESOLVE:
+ return CliMessages.MESSAGES.downloadingArtifacts();
+ case TRACK_RESOLVING_VERSIONS:
+ return CliMessages.MESSAGES.resolvingVersions();
+ default:
+ return stage;
+ }
+ }
+
+ private class ProgressPrinterTask extends TimerTask {
+ private final String currentState;
+ int counter;
+
+ public ProgressPrinterTask(String currentState) {
+ this.currentState = currentState;
+ counter = 0;
+ }
+
+ @Override
+ public void run() {
+ counter++;
+ if (counter > 10) {
+ counter = 1;
+ }
+ final String progress = ".".repeat(Math.max(0, counter));
+ final String line2 = currentState + " " + progress;
+ linePrinter.print(line2);
+ }
+ }
+}
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 8e4cd917b..3cb9d796c 100644
--- a/prospero-cli/src/main/resources/UsageMessages.properties
+++ b/prospero-cli/src/main/resources/UsageMessages.properties
@@ -192,6 +192,11 @@ 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.
+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}.
@@ -200,6 +205,11 @@ no-conflicts-only = Rejects the operation if any file conflicts are detected. If
confirm automatic conflict resolution, unless @|bold --yes|@ option is used.
dry-run = Prints the changes that would be performed by executing the command, but does not perform any changes on the filesystem.
+${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.
+${prospero.dist.name}.certificate.validate-server.usage.header = Verifies if the server contains only correctly signed artifacts.
#
# Exit Codes
#
@@ -418,4 +428,35 @@ 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
+
+prospero.verify-server.trusted_certificates.header=The components installed in this server were signed with following certificates:
+prospero.verify-server.trusted_components_only.header=All server components are correctly signed.
+prospero.verify-server.unverified_components.header=Some of the server components are not from a trusted source:
+prospero.verify-server.modified_files.header=Following server files have been modified since installation:
+prospero.verify-server.error.signature_not_found=Detached signature not found
+prospero.verify-server.error.untrusted_public_key=Signed with untrusted public key: %s
+prospero.verify-server.error.invalid_local_file=Doesn't match the signature. The file might have been tampered with.
+prospero.verify-server.error.unknown_error=Unknown error %s
\ No newline at end of file
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-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/cli/commands/certificate/ValidateServerOriginCommandTest.java b/prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/certificate/ValidateServerOriginCommandTest.java
new file mode 100644
index 000000000..cd4d181cb
--- /dev/null
+++ b/prospero-cli/src/test/java/org/wildfly/prospero/cli/commands/certificate/ValidateServerOriginCommandTest.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.cli.commands.certificate;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+
+import org.assertj.core.api.Assertions;
+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.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.wildfly.channel.ChannelManifest;
+import org.wildfly.channel.spi.SignatureResult;
+import org.wildfly.prospero.actions.CertificateAction;
+import org.wildfly.prospero.actions.VerificationResult;
+import org.wildfly.prospero.api.MavenOptions;
+import org.wildfly.prospero.cli.ActionFactory;
+import org.wildfly.prospero.cli.CliMessages;
+import org.wildfly.prospero.cli.ReturnCodes;
+import org.wildfly.prospero.cli.commands.AbstractMavenCommandTest;
+import org.wildfly.prospero.cli.commands.CliConstants;
+import org.wildfly.prospero.test.MetadataTestUtils;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ValidateServerOriginCommandTest extends AbstractMavenCommandTest {
+
+ @Mock
+ private ActionFactory actionFactory;
+
+ @Mock
+ private CertificateAction certificateAction;
+
+ @Captor
+ private ArgumentCaptor mavenOptions;
+
+ @Rule
+ public TemporaryFolder tempDir = new TemporaryFolder();
+
+ private Path dir;
+
+ @Override
+ protected ActionFactory createActionFactory() {
+ return actionFactory;
+ }
+
+ @Before
+ public void setUp() throws Exception {
+ super.setUp();
+ when(actionFactory.certificateAction(any())).thenReturn(certificateAction);
+
+ this.dir = tempDir.newFolder().toPath();
+ MetadataTestUtils.createInstallationMetadata(dir, new ChannelManifest(null, null, null, null),
+ Collections.emptyList());
+ MetadataTestUtils.createGalleonProvisionedState(dir);
+ }
+
+ @Test
+ public void printPositiveResultWhenNoInvalidComponents() throws Exception {
+ when(certificateAction.verifyServerOrigin(any(), any())).thenReturn(new VerificationResult(
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptySet()
+ ));
+
+ int exitCode = commandLine.execute(getDefaultArguments());
+
+ Assert.assertEquals(ReturnCodes.SUCCESS, exitCode);
+
+ Assertions.assertThat(getStandardOutput())
+ .contains(CliMessages.MESSAGES.verifiedComponentsOnly());
+ }
+
+ @Test
+ public void printListOfInvalidArtifactsIfPresent() throws Exception {
+ when(certificateAction.verifyServerOrigin(any(), any())).thenReturn(new VerificationResult(
+ List.of(new VerificationResult.InvalidBinary(Path.of("test.jar"), "test:test", SignatureResult.Result.INVALID)),
+ Collections.emptyList(),
+ Collections.emptySet()
+ ));
+
+ int exitCode = commandLine.execute(getDefaultArguments());
+
+ Assert.assertEquals(ReturnCodes.PROCESSING_ERROR, exitCode);
+
+ Assertions.assertThat(getStandardOutput())
+ .contains(CliMessages.MESSAGES.unverifiedComponentsListHeader())
+ .contains(" * test.jar : " + CliMessages.MESSAGES.componentInvalidLocalFile() + "\n");
+ }
+
+ @Override
+ protected void doLocalMock() throws Exception {
+ Mockito.when(certificateAction.verifyServerOrigin(any(), any())).thenReturn(new VerificationResult(
+ Collections.emptyList(),
+ Collections.emptyList(),
+ Collections.emptySet()
+ ));
+ }
+
+ @Override
+ protected MavenOptions getCapturedMavenOptions() throws Exception {
+ Mockito.verify(certificateAction).verifyServerOrigin(any(), mavenOptions.capture());
+ return mavenOptions.getValue();
+ }
+
+ @Override
+ protected String[] getDefaultArguments() {
+ return new String[]{CliConstants.Commands.CERTIFICATE, CliConstants.Commands.VALIDATE_SERVER, CliConstants.DIR, dir.toAbsolutePath().toString()};
+ }
+}
\ 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/pom.xml b/prospero-common/pom.xml
index 70f85fe6a..10e698776 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 bba33737a..745a81aa7 100644
--- a/prospero-common/src/main/java/org/wildfly/prospero/ProsperoLogger.java
+++ b/prospero-common/src/main/java/org/wildfly/prospero/ProsperoLogger.java
@@ -25,15 +25,21 @@
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.ApplyCandidateAction;
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;
@@ -403,4 +409,18 @@ public interface ProsperoLogger extends BasicLogger {
@Message(id = 275, value = "The candidate at [%s] was not prepared for %s operation.")
InvalidUpdateCandidateException wrongCandidateOperation(Path candidateServer, ApplyCandidateAction.Type operationType);
+ @Message(id = 276, value = "The certificate at %s is invalid - %s")
+ InvalidCertificateException invalidCertificate(String certLocation, String message, @Cause Exception e);
+
+ @Message(id = 277, value = "Unable to persist changes to local keystore %s - %s")
+ KeystoreWriteException unableToWriteKeystore(Path location, String message, @Cause Exception e);
+
+ @Message(id = 278, value = "Unable to import a certificate - certificate %s already exists in the keystore.")
+ DuplicatedCertificateException certificateAlreadyExists(@Param @Pos(1) String keyID);
+
+ @Message(id = 279, value = "The key %s was not found in the keyring.")
+ NoSuchCertificateException noSuchCertificate(String keyId);
+
+ @Message(id = 280, 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/actions/CertificateAction.java b/prospero-common/src/main/java/org/wildfly/prospero/actions/CertificateAction.java
new file mode 100644
index 000000000..f3bc3d246
--- /dev/null
+++ b/prospero-common/src/main/java/org/wildfly/prospero/actions/CertificateAction.java
@@ -0,0 +1,165 @@
+/*
+ * 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.jboss.galleon.ProvisioningException;
+import org.wildfly.prospero.api.MavenOptions;
+import org.wildfly.prospero.api.ProvisioningProgressEvent;
+import org.wildfly.prospero.api.exceptions.OperationException;
+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 org.wildfly.prospero.wfchannel.MavenSessionManager;
+
+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;
+ private final Path installationDir;
+
+ public CertificateAction(Path installationDir) throws MetadataException {
+ final Path keyringPath = installationDir.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg");
+ localGpgKeystore = KeystoreManager.keystoreFor(keyringPath);
+
+ this.installationDir = installationDir;
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * verify that all the components installed in the server are signed with one of the trusted certificates.
+ *
+ * @param console
+ * @param mavenOptions
+ * @return
+ * @throws ProvisioningException
+ * @throws OperationException
+ */
+ public VerificationResult verifyServerOrigin(VerificationListener console, MavenOptions mavenOptions) throws ProvisioningException, OperationException {
+ return new VerifyServerOriginAction(installationDir, new MavenSessionManager(mavenOptions), console)
+ .verify();
+ }
+
+ private List asList(Iterator publicKeys) {
+ final ArrayList res = new ArrayList<>();
+ while (publicKeys.hasNext()) {
+ res.add(publicKeys.next());
+ }
+ return res;
+ }
+
+ @Override
+ public void close() {
+ localGpgKeystore.close();
+ }
+
+ public interface VerificationListener {
+ void progressUpdate(ProvisioningProgressEvent update);
+ void provisionReferenceServerStarted();
+ void provisionReferenceServerFinished();
+ void validatingComponentsStarted();
+ void validatingComponentsFinished();
+ void checkingModifiedFilesStarted();
+ void checkingModifiedFilesFinished();
+ }
+}
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 6cd3aab1e..3ca011b47 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());
}
@@ -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) {
@@ -741,15 +743,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 8f479894f..b0c171c2e 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/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());
+ }
}
}
}
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 f75fd9da0..758f275e3 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
@@ -27,7 +27,9 @@
import org.wildfly.channel.UnresolvedMavenArtifactException;
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.ProvisioningProgressEvent;
import org.wildfly.prospero.api.SavedState;
import org.wildfly.prospero.api.exceptions.ArtifactResolutionException;
import org.wildfly.prospero.api.exceptions.MetadataException;
@@ -66,13 +68,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,
@@ -156,8 +161,13 @@ 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,
+ new NoProgressConsole(),
+ installDir.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg")
+ );
+ galleonFeaturePackAnalyzer.cacheGalleonArtifacts(targetDir, installDir, provisioningConfig);
} catch (Exception e) {
throw new RuntimeException(e);
}
@@ -219,4 +229,22 @@ private void writeCandidateProperties(UpdateSet updateSet, Path installationDir)
ProsperoLogger.ROOT_LOGGER.unableToWriteChannelNamesToFile(candidateFile.toFile().getAbsolutePath(),e);
}
}
+
+ private class NoProgressConsole implements Console {
+
+ @Override
+ public void progressUpdate(ProvisioningProgressEvent update) {
+ // ignore the progress updates - we're not actually provisioning the server now
+ }
+
+ @Override
+ public void println(String text) {
+ console.println(text);
+ }
+
+ @Override
+ public boolean acceptPublicKey(String key) {
+ return console.acceptPublicKey(key);
+ }
+ }
}
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 7bca82a87..00b09869a 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.DefaultRepositorySystemSession;
import org.eclipse.aether.RepositorySystem;
@@ -61,11 +62,13 @@
import org.wildfly.prospero.metadata.ManifestVersionResolver;
import org.wildfly.prospero.metadata.ProsperoMetadataUtils;
import org.wildfly.prospero.model.ProsperoConfig;
+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;
-public class ProvisioningAction {
+public class ProvisioningAction implements AutoCloseable {
private static final String CHANNEL_NAME_PREFIX = "channel-";
private final MavenSessionManager mavenSessionManager;
@@ -73,13 +76,28 @@ public class ProvisioningAction {
private final Console console;
private final LicenseManager licenseManager;
private final MavenOptions mvnOptions;
+ 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);
}
@@ -121,10 +139,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);
- final GalleonFeaturePackAnalyzer exporter = new GalleonFeaturePackAnalyzer(channels, mavenSessionManager);
+ final GalleonFeaturePackAnalyzer exporter = new GalleonFeaturePackAnalyzer(channels, mavenSessionManager,
+ console, keyringPath);
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);
@@ -234,7 +267,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);
}
}
@@ -251,9 +284,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;
}
@@ -284,4 +317,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/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/actions/VerificationResult.java b/prospero-common/src/main/java/org/wildfly/prospero/actions/VerificationResult.java
new file mode 100644
index 000000000..26af17e8c
--- /dev/null
+++ b/prospero-common/src/main/java/org/wildfly/prospero/actions/VerificationResult.java
@@ -0,0 +1,107 @@
+/*
+ * 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 java.nio.file.Path;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+import org.wildfly.channel.spi.SignatureResult;
+import org.wildfly.prospero.signatures.PGPPublicKeyInfo;
+
+public class VerificationResult {
+ private final List invalidBinaries;
+ private final List modifiedFiles;
+ private final Set trustedCertificates;
+
+ public VerificationResult(List invalidBinaries, List modifiedFiles, Set trustedCertificates) {
+ this.invalidBinaries = invalidBinaries;
+ this.modifiedFiles = modifiedFiles;
+ this.trustedCertificates = trustedCertificates;
+ }
+
+ public Collection getUnsignedBinary() {
+ return invalidBinaries;
+ }
+
+ public Collection getModifiedFiles() {
+ return modifiedFiles;
+ }
+
+ public Collection getTrustedCertificates() {
+ return trustedCertificates;
+ }
+
+ public static class InvalidBinary {
+ private final Path path;
+ private final String gav;
+ private final SignatureResult.Result error;
+ private final String keyId;
+
+ public InvalidBinary(Path path, String gav, SignatureResult.Result error) {
+ this(path, gav, error, null);
+ }
+
+ public InvalidBinary(Path path, String gav, SignatureResult.Result error, String keyId) {
+ this.path = path;
+ this.gav = gav;
+ this.error = error;
+ this.keyId = keyId;
+ }
+
+ public Path getPath() {
+ return path;
+ }
+
+ public String getGav() {
+ return gav;
+ }
+
+ public SignatureResult.Result getError() {
+ return error;
+ }
+
+ public String getKeyId() {
+ return keyId;
+ }
+
+ @Override
+ public String toString() {
+ return "InvalidBinary{" +
+ "path=" + path +
+ ", gav='" + gav + '\'' +
+ ", error=" + error +
+ ", keyId='" + keyId + '\'' +
+ '}';
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ InvalidBinary that = (InvalidBinary) o;
+ return Objects.equals(path, that.path) && Objects.equals(gav, that.gav) && error == that.error && Objects.equals(keyId, that.keyId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(path, gav, error, keyId);
+ }
+ }
+}
diff --git a/prospero-common/src/main/java/org/wildfly/prospero/actions/VerifyServerOriginAction.java b/prospero-common/src/main/java/org/wildfly/prospero/actions/VerifyServerOriginAction.java
new file mode 100644
index 000000000..c29d4d056
--- /dev/null
+++ b/prospero-common/src/main/java/org/wildfly/prospero/actions/VerifyServerOriginAction.java
@@ -0,0 +1,341 @@
+/*
+ * 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 java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.net.MalformedURLException;
+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.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.commons.io.FileUtils;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.jboss.galleon.Constants;
+import org.jboss.galleon.ProvisioningException;
+import org.jboss.galleon.api.config.GalleonProvisioningConfig;
+import org.jboss.galleon.util.HashUtils;
+import org.wildfly.channel.ArtifactTransferException;
+import org.wildfly.channel.Channel;
+import org.wildfly.channel.ChannelSession;
+import org.wildfly.channel.MavenArtifact;
+import org.wildfly.channel.gpg.GpgSignatureValidator;
+import org.wildfly.channel.gpg.GpgSignatureValidatorListener;
+import org.wildfly.channel.spi.ArtifactIdentifier;
+import org.wildfly.channel.spi.SignatureResult;
+import org.wildfly.prospero.ProsperoLogger;
+import org.wildfly.prospero.api.Console;
+import org.wildfly.prospero.api.InstallationMetadata;
+import org.wildfly.prospero.api.ProvisioningProgressEvent;
+import org.wildfly.prospero.api.exceptions.MetadataException;
+import org.wildfly.prospero.api.exceptions.OperationException;
+import org.wildfly.prospero.galleon.ArtifactCache;
+import org.wildfly.prospero.galleon.GalleonEnvironment;
+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.PGPPublicKeyInfo;
+import org.wildfly.prospero.wfchannel.MavenSessionManager;
+
+class VerifyServerOriginAction {
+
+ private final Path installPath;
+ private final MavenSessionManager mavenSessionManager;
+ private final CertificateAction.VerificationListener console;
+
+ VerifyServerOriginAction(Path installPath, MavenSessionManager mavenSessionManager, CertificateAction.VerificationListener console) {
+ this.installPath = installPath;
+ this.mavenSessionManager = mavenSessionManager;
+ this.console = console;
+ }
+
+ VerificationResult verify() throws ProvisioningException, OperationException {
+ final Path refServerDir;
+ try {
+ refServerDir = Files.createTempDirectory("prospero-candidate");
+ if (ProsperoLogger.ROOT_LOGGER.isDebugEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.debug("Created temporary folder in " + refServerDir);
+ }
+ } catch (IOException e) {
+ throw ProsperoLogger.ROOT_LOGGER.unableToCreateTemporaryFile(e);
+ }
+
+ final List invalidBinaries = new ArrayList<>();
+ final List modifiedPaths = new ArrayList<>();
+ final Set trustedCertificates = new HashSet<>();
+ try {
+ final GalleonEnvironment env = buildReferenceServer(refServerDir);
+
+ // now we verify that all the artifacts in the generated server are correctly signed
+ // we don't verify the artifacts during the provisioning to catch all unsigned artifacts
+ validateBinaries(env, refServerDir, invalidBinaries, trustedCertificates);
+
+ // compare the generated server with original server to find any locally corrupted/unsigned files
+ // any file that is not present in the generated reference server is considered not signed - as we don't know the GAV
+ findLocallyModifiedFiles(refServerDir, invalidBinaries, modifiedPaths);
+
+ } catch (IOException e) {
+ throw new RuntimeException("Unable to perform I/O operation", e);
+ } finally {
+ // cleanup after ourselves - remove the generated server
+ if (refServerDir != null) {
+ if (ProsperoLogger.ROOT_LOGGER.isDebugEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.debug("Removing temporary folder in " + refServerDir);
+ }
+ FileUtils.deleteQuietly(refServerDir.toFile());
+ }
+ }
+
+ // re-provision the server using the cache from existing server and accepting channel setting
+ return new VerificationResult(invalidBinaries, modifiedPaths, trustedCertificates);
+ }
+
+ private void findLocallyModifiedFiles(Path refServerDir, List invalidBinaries, List modifiedPaths) throws IOException {
+ console.checkingModifiedFilesStarted();
+ if (ProsperoLogger.ROOT_LOGGER.isDebugEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.debug("Starting checking local file modifications");
+ }
+
+ Files.walkFileTree(installPath, new SimpleFileVisitor<>() {
+
+ @Override
+ public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
+ final Path relativePath = installPath.relativize(dir);
+ if (relativePath.startsWith(ProsperoMetadataUtils.METADATA_DIR) || relativePath.startsWith(Constants.PROVISIONED_STATE_DIR)){
+ return FileVisitResult.SKIP_SUBTREE;
+ } else {
+ return FileVisitResult.CONTINUE;
+ }
+ }
+
+ @Override
+ public FileVisitResult visitFile(Path originalFile, BasicFileAttributes attrs) throws IOException {
+ final Path relativePath = installPath.relativize(originalFile);
+ final Path referenceFile = refServerDir.resolve(relativePath);
+
+ if (ProsperoLogger.ROOT_LOGGER.isTraceEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.trace("Comparing " + originalFile + " and " + referenceFile);
+ }
+
+ if (!Files.exists(referenceFile)) {
+ if (ProsperoLogger.ROOT_LOGGER.isDebugEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.debug("File " + relativePath + " doesn't exist in the reference server");
+ }
+
+ invalidBinaries.add(new VerificationResult.InvalidBinary(relativePath, null, SignatureResult.Result.NO_SIGNATURE));
+ } else if (!HashUtils.hashFile(referenceFile).equals(HashUtils.hashFile(originalFile))) {
+ // check that it has not already been identified as corrupted binary
+ if (invalidBinaries.stream().map(VerificationResult.InvalidBinary::getPath).noneMatch(p->p.equals(relativePath))) {
+ if (ProsperoLogger.ROOT_LOGGER.isDebugEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.debug("File " + relativePath + " has local modifications");
+ }
+ modifiedPaths.add(relativePath);
+ }
+ }
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ console.checkingModifiedFilesFinished();
+ }
+
+ private void validateBinaries(GalleonEnvironment env, Path refServerDir, List invalidBinaries, Set trustedCertificates)
+ throws IOException, MetadataException {
+ console.validatingComponentsStarted();
+ if (ProsperoLogger.ROOT_LOGGER.isDebugEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.debug("Starting validation of server components");
+ }
+
+ final ChannelSession channelSession = env.getChannelSession();
+
+ if (ProsperoLogger.ROOT_LOGGER.isDebugEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.debug("Using artifacts recorded in " + refServerDir);
+ }
+ final ArtifactCache cache = ArtifactCache.getInstance(refServerDir);
+ final List cachedArtifacts = cache.listArtifacts();
+
+ final Path keystorePath = installPath.resolve(ProsperoMetadataUtils.METADATA_DIR).resolve("keyring.gpg");
+ if (ProsperoLogger.ROOT_LOGGER.isDebugEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.debug("Using signatures trusted by " + keystorePath);
+ }
+ final GpgSignatureValidator validator = new GpgSignatureValidator(new ConfirmingKeystoreAdapter(
+ KeystoreManager.keystoreFor(keystorePath),
+ (desc) -> false));
+
+ // use the listener to record trusted signatures
+ validator.addListener(new GpgSignatureValidatorListener() {
+ @Override
+ public void artifactSignatureCorrect(ArtifactIdentifier artifact, PGPPublicKey publicKey) {
+ trustedCertificates.add(PGPPublicKeyInfo.parse(publicKey));
+ }
+
+ @Override
+ public void artifactSignatureInvalid(ArtifactIdentifier artifact, PGPPublicKey publicKey) {
+ // ignore
+ }
+ });
+
+ cachedArtifacts.stream().parallel().forEach(artifact-> {
+ // now, let's try to validate this artifact
+ // need to first download the signatures
+ final MavenArtifact signature = downloadSignature(artifact, channelSession);
+ if (signature == null) {
+ if (ProsperoLogger.ROOT_LOGGER.isDebugEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.debug("Not able to resolve signature for " + artifact);
+ }
+
+ final Path relativePath = refServerDir.relativize(artifact.getPath());
+ invalidBinaries.add(new VerificationResult.InvalidBinary(relativePath, artifact.getGav(), SignatureResult.Result.NO_SIGNATURE));
+ return;
+ }
+
+ final ArtifactIdentifier.MavenResource artifactIdent = new ArtifactIdentifier.MavenResource(
+ artifact.getGroupId(),
+ artifact.getArtifactId(),
+ artifact.getExtension(),
+ artifact.getClassifier(),
+ artifact.getVersion()
+ );
+
+ final Path relativePath = refServerDir.relativize(artifact.getPath());
+ Path checkedPath = installPath.resolve(relativePath);
+ // check the binary in the original server if available. If not, check downloaded to verify e.g. feature pack is valid
+ if (!Files.exists(checkedPath)) {
+ checkedPath = artifact.getPath();
+ }
+ if (ProsperoLogger.ROOT_LOGGER.isTraceEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.trace("Verifying signature of " + checkedPath);
+ }
+
+ final SignatureResult signatureResult;
+ try {
+ signatureResult = validator.validateSignature(
+ artifactIdent,
+ new FileInputStream(checkedPath.toFile()),
+ new FileInputStream(signature.getFile()), Collections.emptyList());
+ } catch (FileNotFoundException e) {
+ throw new RuntimeException(e);
+ }
+
+ if (ProsperoLogger.ROOT_LOGGER.isTraceEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.trace("Artifact " + artifactIdent + " verification result: " + signatureResult);
+ }
+ if (signatureResult.getResult() != SignatureResult.Result.OK) {
+ invalidBinaries.add(new VerificationResult.InvalidBinary(relativePath, artifact.getGav(), signatureResult.getResult(), signatureResult.getKeyId()));
+ }
+ });
+ console.validatingComponentsFinished();
+ }
+
+ private static MavenArtifact downloadSignature(ArtifactCache.CachedArtifact artifact, ChannelSession channelSession) {
+ try {
+ if (ProsperoLogger.ROOT_LOGGER.isTraceEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.trace("Downloading signature for " + artifact);
+ }
+ return channelSession.resolveDirectMavenArtifact(
+ artifact.getGroupId(),
+ artifact.getArtifactId(),
+ artifact.getExtension() + ".asc",
+ artifact.getClassifier(),
+ artifact.getVersion());
+ } catch (ArtifactTransferException e) {
+ return null;
+ }
+ }
+
+ private GalleonEnvironment buildReferenceServer(Path tempServerDir) throws ProvisioningException, OperationException {
+ console.provisionReferenceServerStarted();
+
+ final ProsperoConfig config;
+ final GalleonProvisioningConfig galleonProvisioningConfig;
+ try (InstallationMetadata metadata = InstallationMetadata.loadInstallation(installPath)) {
+ if (ProsperoLogger.ROOT_LOGGER.isDebugEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.debug("Loading existing server metadata from " + installPath);
+ }
+
+ config = metadata.getProsperoConfig();
+ galleonProvisioningConfig = metadata.getGalleonProvisioningConfig();
+ }
+
+ final AdapterConsole adapterConsole = new AdapterConsole();
+
+ final List channels = config.getChannels().stream()
+ .map(c->new Channel.Builder(c)
+ .setGpgCheck(false)
+ .setManifestUrl(getExistingManifestUrl())
+ .build())
+ .collect(Collectors.toList());
+ if (ProsperoLogger.ROOT_LOGGER.isDebugEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.debug("Using channel definition " + channels);
+ }
+
+ final GalleonEnvironment env = GalleonEnvironment
+ .builder(tempServerDir, channels, mavenSessionManager, false)
+ .setConsole(adapterConsole)
+ .build();
+
+ try (PrepareCandidateAction prepareCandidateAction = new PrepareCandidateAction(installPath, mavenSessionManager, config, adapterConsole)) {
+ if (ProsperoLogger.ROOT_LOGGER.isDebugEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.debug("Provisioning reference server in " + tempServerDir);
+ }
+
+ prepareCandidateAction.buildCandidate(tempServerDir, env, ApplyCandidateAction.Type.UPDATE, galleonProvisioningConfig);
+
+ if (ProsperoLogger.ROOT_LOGGER.isDebugEnabled()) {
+ ProsperoLogger.ROOT_LOGGER.debug("Finished provisioning reference server in " + tempServerDir);
+ }
+ }
+ console.provisionReferenceServerFinished();
+ return env;
+ }
+
+ private URL getExistingManifestUrl() {
+ try {
+ return ProsperoMetadataUtils.manifestPath(installPath).toUri().toURL();
+ } catch (MalformedURLException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private class AdapterConsole implements Console {
+ @Override
+ public void progressUpdate(ProvisioningProgressEvent update) {
+ console.progressUpdate(update);
+ }
+
+ @Override
+ public void println(String text) {
+ // noop
+ }
+
+ @Override
+ public boolean acceptPublicKey(String key) {
+ // always reject unknown keys
+ return false;
+ }
+ }
+}
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/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/api/TemporaryRepositoriesHandler.java b/prospero-common/src/main/java/org/wildfly/prospero/api/TemporaryRepositoriesHandler.java
index 97fafa6de..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);
@@ -37,8 +50,10 @@ 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)
+ .setGpgCheck(false)
+ .build();
mergedChannels.add(c);
}
diff --git a/prospero-common/src/main/java/org/wildfly/prospero/galleon/ArtifactCache.java b/prospero-common/src/main/java/org/wildfly/prospero/galleon/ArtifactCache.java
index 13bfdb3ab..700e4f871 100644
--- a/prospero-common/src/main/java/org/wildfly/prospero/galleon/ArtifactCache.java
+++ b/prospero-common/src/main/java/org/wildfly/prospero/galleon/ArtifactCache.java
@@ -37,11 +37,13 @@
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
@@ -276,4 +278,110 @@ private static String asKey(String groupId, String artifactId, String extension,
}
return buf.append(':').append(version).toString();
}
+
+ public List listArtifacts() {
+ final List cacheEntries = new ArrayList<>();
+
+ final Set artifactKeys = paths.keySet();
+ for (String artifactKey : artifactKeys) {
+ final Path artifactPath = paths.get(artifactKey);
+ final String artifactHash = hashes.get(artifactKey);
+
+ final String[] gav = artifactKey.split(":");
+ final String groupId = gav[0];
+ final String artifactId = gav[1];
+ final String extension = gav[2];
+ final String classifier;
+ final String version;
+ if (gav.length == 5) {
+ classifier = gav[3];
+ version = gav[4];
+ } else {
+ classifier = null;
+ version = gav[3];
+ }
+
+
+ cacheEntries.add(new CachedArtifact(groupId, artifactId, extension, classifier, version, artifactHash, artifactPath));
+ }
+ return cacheEntries;
+ }
+
+ public static class CachedArtifact {
+ private final String groupId;
+ private final String artifactId;
+ private final String version;
+ private final String classifier;
+ private final String extension;
+ private final Path path;
+ private final String hash;
+
+ CachedArtifact(String groupId, String artifactId, String extension, String classifier, String version, String hash, Path path) {
+ this.groupId = groupId;
+ this.artifactId = artifactId;
+ this.extension = extension;
+ this.classifier = classifier;
+ this.version = version;
+ this.hash = hash;
+ this.path = path;
+ }
+
+ public String getGav() {
+ return ArtifactCache.asKey(groupId, artifactId, extension, classifier, version);
+ }
+
+ public Path getPath() {
+ return path;
+ }
+
+ public String getHash() {
+ return hash;
+ }
+
+ public String getGroupId() {
+ return groupId;
+ }
+
+ public String getArtifactId() {
+ return artifactId;
+ }
+
+ public String getVersion() {
+ return version;
+ }
+
+ public String getExtension() {
+ return extension;
+ }
+
+ public String getClassifier() {
+ return classifier;
+ }
+
+ @Override
+ public String toString() {
+ return "CachedArtifact{" +
+ "groupId='" + groupId + '\'' +
+ ", artifactId='" + artifactId + '\'' +
+ ", version='" + version + '\'' +
+ ", classifier='" + classifier + '\'' +
+ ", extension='" + extension + '\'' +
+ ", path=" + path +
+ ", hash='" + hash + '\'' +
+ '}';
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ CachedArtifact that = (CachedArtifact) o;
+ return Objects.equals(groupId, that.groupId) && Objects.equals(artifactId, that.artifactId) && Objects.equals(version, that.version) && Objects.equals(classifier, that.classifier) && Objects.equals(extension, that.extension) && Objects.equals(path, that.path) && Objects.equals(hash, that.hash);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(groupId, artifactId, version, classifier, extension, path, hash);
+ }
+ }
}
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..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
@@ -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,8 +42,13 @@
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.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.MalformedURLException;
@@ -57,6 +63,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,15 +87,21 @@ public class GalleonEnvironment implements AutoCloseable {
private Path restoreManifestPath = null;
private boolean resetGalleonLineEndings = true;
+ private PGPLocalKeystore localGpgKeystore;
+ private TemporaryManifestSignature temporaryManifestSignature;
- 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);
+
+ 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;
}
@@ -99,15 +112,22 @@ private GalleonEnvironment(Builder builder) throws ProvisioningException, Metada
substitutedChannels.add(substitutor.substitute(channel));
}
+ 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, 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, MavenProxyHandler::addProxySettings);
+ factory = new VersionResolverFactory(system, session, signatureValidator, MavenProxyHandler::addProxySettings);
}
channelSession = initChannelSession(session, factory);
@@ -151,9 +171,30 @@ private GalleonEnvironment(Builder builder) throws ProvisioningException, Metada
provisioning.setProgressCallback(TRACK_JB_ARTIFACTS_RESOLVE, callback);
}
+ 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;
+ } 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) {
@@ -181,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");
@@ -191,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);
}
@@ -203,11 +252,24 @@ private List replaceManifestWithRestoreManifests(Builder builder, Optio
c.getRepositories(),
manifestCoord,
c.getBlocklistCoordinate(),
- c.getNoStreamStrategy()))
+ c.getNoStreamStrategy(),
+ c.isGpgCheck(),
+ c.getGpgUrls()))
.collect(Collectors.toList());
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 {
@@ -258,6 +320,12 @@ public void close() {
if (restoreManifestPath != null) {
FileUtils.deleteQuietly(restoreManifestPath.toFile());
}
+ if (temporaryManifestSignature != null) {
+ temporaryManifestSignature.close();
+ }
+ if (localGpgKeystore != null) {
+ localGpgKeystore.close();
+ }
provisioning.close();
}
@@ -281,6 +349,7 @@ public static class Builder {
private boolean artifactDirectResolve;
private List restoredManifestVersions;
private final boolean useDefaultCore;
+ private Path keyringLocation;
private GalleonProvisioningConfig config;
@@ -344,8 +413,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/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/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/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/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/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/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..947f788e9
--- /dev/null
+++ b/prospero-common/src/main/java/org/wildfly/prospero/signatures/PGPPublicKeyInfo.java
@@ -0,0 +1,169 @@
+/*
+ * 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 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 String certName = file.getAbsolutePath();
+ try {
+ return parse(new FileInputStream(file), certName);
+ } catch (IOException e) {
+ throw ProsperoLogger.ROOT_LOGGER.invalidCertificate(certName, e.getLocalizedMessage(), e);
+ }
+ }
+ public static PGPPublicKeyInfo parse(InputStream is, String certName) throws InvalidCertificateException {
+ final PGPPublicKeyRing pgpPublicKeys;
+ try {
+ pgpPublicKeys = new PGPPublicKeyRing(new ArmoredInputStream(is), new JcaKeyFingerprintCalculator());
+ } catch (IOException e) {
+ throw ProsperoLogger.ROOT_LOGGER.invalidCertificate(certName, 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);
+ }
+
+ public static PGPPublicKeyInfo parse(PGPPublicKey key) {
+ 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/main/java/org/wildfly/prospero/spi/ProsperoInstallationManager.java b/prospero-common/src/main/java/org/wildfly/prospero/spi/ProsperoInstallationManager.java
index 5f2c4d376..4ed92b5eb 100644
--- a/prospero-common/src/main/java/org/wildfly/prospero/spi/ProsperoInstallationManager.java
+++ b/prospero-common/src/main/java/org/wildfly/prospero/spi/ProsperoInstallationManager.java
@@ -4,6 +4,8 @@
import org.jboss.logging.Logger;
import org.wildfly.channel.ChannelManifestCoordinate;
import org.wildfly.channel.MavenCoordinate;
+import org.wildfly.channel.spi.SignatureResult;
+import org.wildfly.channel.spi.SignatureValidator;
import org.wildfly.installationmanager.ArtifactChange;
import org.wildfly.installationmanager.CandidateType;
import org.wildfly.installationmanager.Channel;
@@ -13,12 +15,15 @@
import org.wildfly.installationmanager.InstallationChanges;
import org.wildfly.installationmanager.ManifestVersion;
import org.wildfly.installationmanager.MavenOptions;
+import org.wildfly.installationmanager.MissingSignatureException;
import org.wildfly.installationmanager.OperationNotAvailableException;
import org.wildfly.installationmanager.Repository;
+import org.wildfly.installationmanager.TrustCertificate;
import org.wildfly.installationmanager.spi.InstallationManager;
import org.wildfly.installationmanager.spi.OsShell;
import org.wildfly.prospero.ProsperoLogger;
import org.wildfly.prospero.actions.ApplyCandidateAction;
+import org.wildfly.prospero.actions.CertificateAction;
import org.wildfly.prospero.actions.InstallationExportAction;
import org.wildfly.prospero.actions.InstallationHistoryAction;
import org.wildfly.prospero.actions.MetadataAction;
@@ -27,12 +32,17 @@
import org.wildfly.prospero.api.exceptions.InvalidUpdateCandidateException;
import org.wildfly.prospero.galleon.GalleonCallbackAdapter;
import org.wildfly.prospero.metadata.ManifestVersionRecord;
+import org.wildfly.prospero.signatures.PGPKeyId;
+import org.wildfly.prospero.signatures.PGPPublicKey;
+import org.wildfly.prospero.signatures.PGPPublicKeyInfo;
import org.wildfly.prospero.spi.internal.CliProvider;
import org.wildfly.prospero.api.SavedState;
import org.wildfly.prospero.api.exceptions.MetadataException;
import org.wildfly.prospero.api.exceptions.OperationException;
import org.wildfly.prospero.updates.UpdateSet;
+import java.io.InputStream;
+import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
@@ -40,11 +50,13 @@
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.ServiceLoader;
+import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -55,6 +67,7 @@ public class ProsperoInstallationManager implements InstallationManager {
private final ActionFactory actionFactory;
private Path installationDir;
+ private MavenOptions mavenOptions;
public ProsperoInstallationManager(Path installationDir, MavenOptions mavenOptions) throws Exception {
final Builder options = org.wildfly.prospero.api.MavenOptions.builder()
@@ -65,6 +78,7 @@ public ProsperoInstallationManager(Path installationDir, MavenOptions mavenOptio
}
actionFactory = new ActionFactory(installationDir, options.build());
this.installationDir = installationDir;
+ this.mavenOptions = mavenOptions;
}
// Used for tests to mock up action creation
@@ -118,7 +132,15 @@ public void prepareRevert(String revision, Path targetDir, List repo
@Override
public boolean prepareUpdate(Path targetDir, List repositories) throws Exception {
try (UpdateAction prepareUpdateAction = actionFactory.getUpdateAction(map(repositories, ProsperoInstallationManager::mapRepository))) {
- return prepareUpdateAction.buildUpdate(targetDir);
+ try {
+ return prepareUpdateAction.buildUpdate(targetDir);
+ } catch (SignatureValidator.SignatureException e) {
+ if (e.getSignatureResult().getResult() == SignatureResult.Result.NO_MATCHING_CERT) {
+ throw new MissingSignatureException(e.getMessage(), e, e.getMissingSignature());
+ } else {
+ throw e;
+ }
+ }
}
}
@@ -158,6 +180,35 @@ public Collection verifyCandidate(Path candidatePath, CandidateTyp
return map(applyCandidateAction.getConflicts(), ProsperoInstallationManager::mapFileConflict);
}
+ @Override
+ public void acceptTrustedCertificates(InputStream certificate) throws Exception {
+ Objects.requireNonNull(certificate);
+ try (CertificateAction certificateAction = actionFactory.getCertificateAction()) {
+ certificateAction.importCertificate(new PGPPublicKey("Imported cert", certificate));
+ }
+ }
+
+ @Override
+ public void revokeTrustedCertificate(String keyID) throws Exception {
+ try (CertificateAction certificateAction = actionFactory.getCertificateAction()) {
+ certificateAction.removeCertificate(new PGPKeyId(keyID));
+ }
+ }
+
+ @Override
+ public Collection listTrustedCertificates() throws Exception {
+ try (CertificateAction certificateAction = actionFactory.getCertificateAction()) {
+ return certificateAction.listCertificates().stream()
+ .map(ProsperoInstallationManager::mapCertificate)
+ .collect(Collectors.toList());
+ }
+ }
+
+ @Override
+ public TrustCertificate parseCertificate(InputStream is) throws Exception {
+ return mapCertificate(PGPPublicKeyInfo.parse(is, "Imported cert"));
+ }
+
private static FileConflict mapFileConflict(org.wildfly.prospero.api.FileConflict fileConflict) {
return new FileConflict(Path.of(fileConflict.getRelativePath()), map(fileConflict.getUserChange()), map(fileConflict.getUpdateChange()), fileConflict.getResolution() == org.wildfly.prospero.api.FileConflict.Resolution.UPDATE);
}
@@ -184,6 +235,12 @@ public List findUpdates(List repositories) throws Ex
return updates.getArtifactUpdates().stream()
.map(ProsperoInstallationManager::mapArtifactChange)
.collect(Collectors.toList());
+ } catch (SignatureValidator.SignatureException e) {
+ if (e.getSignatureResult().getResult() == SignatureResult.Result.NO_MATCHING_CERT) {
+ throw new MissingSignatureException(e.getMessage(), e, e.getMissingSignature());
+ } else {
+ throw e;
+ }
}
}
@@ -301,6 +358,43 @@ public Collection getInstalledVersions() throws MetadataExcepti
}
}
+ @Override
+ public Collection downloadRequiredCertificates() throws Exception {
+ ArrayList missingCerts = new ArrayList<>();
+ try (MetadataAction metadataAction = actionFactory.getMetadataAction();
+ // TODO: replace with actionFactory
+ CertificateAction certificateAction = new CertificateAction(installationDir)) {
+ final List urls = metadataAction.getChannels().stream()
+ .map(org.wildfly.channel.Channel::getGpgUrls)
+ .flatMap(List::stream)
+ .collect(Collectors.toList());
+
+ int counter = 0;
+ final Set discoveredKeys = new HashSet<>();
+ for (String urlText : urls) {
+ if (this.mavenOptions.isOffline() && !(urlText.startsWith("file") || urlText.startsWith("classpath"))) {
+ // ignore remote certificates if we're offline
+ continue;
+ }
+ // resolve cert URLs
+ final URL url = new URL(urlText);
+ // parse cert files
+ final TrustCertificate tc = parseCertificate(url.openStream());
+ final PGPKeyId keyId = new PGPKeyId(tc.getKeyID());
+ // check keyIDs of the certs vs. trusted certs
+ if (!discoveredKeys.contains(keyId) && certificateAction.listCertificates().stream()
+ .noneMatch(c->c.getKeyID().equals(keyId))) {
+ // if any are missing, return them
+ missingCerts.add(url.openStream());
+
+ discoveredKeys.add(keyId);
+ }
+ }
+
+ }
+ return missingCerts;
+ }
+
private String escape(Path absolutePath) {
return "\"" + absolutePath.toString() + "\"";
}
@@ -385,6 +479,13 @@ private static ChannelChange mapChannelChange(org.wildfly.prospero.api.ChannelCh
}
}
+ private static TrustCertificate mapCertificate(PGPPublicKeyInfo keyInfo) {
+ return new TrustCertificate(keyInfo.getKeyID().getHexKeyID(),
+ keyInfo.getFingerprint(),
+ String.join("; ", keyInfo.getIdentity()),
+ keyInfo.getStatus().toString());
+ }
+
ActionFactory getActionFactory() {
return actionFactory;
}
@@ -422,5 +523,9 @@ protected ApplyCandidateAction getApplyCandidateAction(Path candidateDir) throws
org.wildfly.prospero.api.MavenOptions getMavenOptions() {
return mavenOptions;
}
+
+ public CertificateAction getCertificateAction() throws MetadataException {
+ return new CertificateAction(server);
+ }
}
}
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
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/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/ArtifactCacheTest.java b/prospero-common/src/test/java/org/wildfly/prospero/galleon/ArtifactCacheTest.java
index 999e39176..0a90da457 100644
--- a/prospero-common/src/test/java/org/wildfly/prospero/galleon/ArtifactCacheTest.java
+++ b/prospero-common/src/test/java/org/wildfly/prospero/galleon/ArtifactCacheTest.java
@@ -17,6 +17,7 @@
package org.wildfly.prospero.galleon;
+import org.jboss.galleon.util.HashUtils;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
@@ -164,4 +165,28 @@ public void cacheRecordsArtifactsInAlphabeticOrder() throws Exception {
assertThat(line.get(1))
.contains(otherArtifact.getGroupId() + ":" + ARTIFACT_ID);
}
+
+ @Test
+ public void listCacheEntryWithEmptyArtifact() throws Exception {
+ assertThat(cache.listArtifacts())
+ .isEmpty();
+ }
+
+ @Test
+ public void listCacheEntryWithSingleArtifact() throws Exception {
+ cache.cache(anArtifact);
+
+ final Optional artifact = cache.getArtifact(
+ anArtifact.getGroupId(),
+ anArtifact.getArtifactId(),
+ anArtifact.getExtension(),
+ anArtifact.getClassifier(),
+ anArtifact.getVersion()
+ );
+ final String hash = HashUtils.hashFile(artifact.get().toPath());
+
+ assertThat(cache.listArtifacts())
+ .containsExactly(new ArtifactCache.CachedArtifact(anArtifact.getGroupId(), anArtifact.getArtifactId(),
+ anArtifact.getExtension(), anArtifact.getClassifier(), anArtifact.getVersion(), hash, artifact.get().toPath()));
+ }
}
\ No newline at end of file
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..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;
@@ -110,7 +125,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 +157,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();
@@ -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/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");
}
diff --git a/prospero-common/src/test/java/org/wildfly/prospero/installation/git/GitStorageTest.java b/prospero-common/src/test/java/org/wildfly/prospero/installation/git/GitStorageTest.java
index 42ef39ab2..712bbf404 100644
--- a/prospero-common/src/test/java/org/wildfly/prospero/installation/git/GitStorageTest.java
+++ b/prospero-common/src/test/java/org/wildfly/prospero/installation/git/GitStorageTest.java
@@ -156,7 +156,8 @@ public void testAddedArtifact() throws Exception {
public void initialRecordStoresConfigState() throws Exception {
final GitStorage gitStorage = new GitStorage(base.getParent());
ProsperoMetadataUtils.writeManifest(base.resolve(ProsperoMetadataUtils.MANIFEST_FILE_NAME), manifest);
- generateProsperoConfig(List.of(new Channel("", "", null, null, null, null, null)));
+ generateProsperoConfig(List.of(
+ new Channel.Builder().build()));
gitStorage.record();
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);
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
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/spi/ProsperoInstallationManagerTest.java b/prospero-common/src/test/java/org/wildfly/prospero/spi/ProsperoInstallationManagerTest.java
index 408e2c618..c7eeffc19 100644
--- a/prospero-common/src/test/java/org/wildfly/prospero/spi/ProsperoInstallationManagerTest.java
+++ b/prospero-common/src/test/java/org/wildfly/prospero/spi/ProsperoInstallationManagerTest.java
@@ -17,10 +17,13 @@
package org.wildfly.prospero.spi;
+import org.assertj.core.api.Assertions;
+import org.bouncycastle.openpgp.PGPSecretKeyRing;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.wildfly.channel.Channel;
@@ -30,17 +33,29 @@
import org.wildfly.installationmanager.FileConflict;
import org.wildfly.installationmanager.InstallationChanges;
import org.wildfly.installationmanager.MavenOptions;
+import org.wildfly.installationmanager.TrustCertificate;
import org.wildfly.prospero.actions.ApplyCandidateAction;
+import org.wildfly.prospero.actions.CertificateAction;
import org.wildfly.prospero.actions.InstallationHistoryAction;
import org.wildfly.prospero.actions.UpdateAction;
import org.wildfly.prospero.api.ChannelChange;
import org.wildfly.prospero.api.SavedState;
+import org.wildfly.prospero.signatures.InvalidCertificateException;
+import org.wildfly.prospero.signatures.PGPKeyId;
+import org.wildfly.prospero.signatures.PGPPublicKey;
+import org.wildfly.prospero.signatures.PGPPublicKeyInfo;
import org.wildfly.prospero.updates.UpdateSet;
+import org.wildfly.prospero.utils.TestSignatureUtils;
+import java.io.File;
+import java.io.FileInputStream;
+import java.nio.file.Files;
import java.nio.file.Path;
+import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
+import java.util.Locale;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -75,6 +90,9 @@ public class ProsperoInstallationManagerTest {
@Mock
private ApplyCandidateAction applyCandidateAction;
+ @Mock
+ private CertificateAction certificateAction;
+
@Rule
public TemporaryFolder temp = new TemporaryFolder();
@@ -285,6 +303,76 @@ public void testCheckUpdatesThrowsVerificationExceptions() throws Exception {
assertThatThrownBy(() -> mgr.verifyCandidate(Path.of("candidate"), CandidateType.UPDATE))
.hasMessageContaining("has been modified after the candidate has been created");
+ }
+
+ @Test
+ public void parseValidCertificate_ReturnsCertificateData() throws Exception {
+ final ProsperoInstallationManager mgr = new ProsperoInstallationManager(actionFactory);
+ final PGPSecretKeyRing pgpSecretKeys = TestSignatureUtils.generateSecretKey("test", "test");
+ final File testCert = temp.newFile("test.crt");
+ TestSignatureUtils.exportPublicKeys(pgpSecretKeys, testCert);
+
+ final TrustCertificate trustCertificate = mgr.parseCertificate(new FileInputStream(testCert));
+
+ assertEquals(Long.toHexString(pgpSecretKeys.getPublicKey().getKeyID()).toUpperCase(Locale.ROOT),
+ trustCertificate.getKeyID());
+ }
+
+ @Test
+ public void parseInvalidCertificate_ThrowsError() throws Exception {
+ final ProsperoInstallationManager mgr = new ProsperoInstallationManager(actionFactory);
+ final File testCert = temp.newFile("test.crt");
+ Files.writeString(testCert.toPath(), "not a real certificate");
+
+ Assertions.assertThatThrownBy(() -> mgr.parseCertificate(new FileInputStream(testCert)))
+ .isInstanceOf(InvalidCertificateException.class);
+ }
+
+ @Test
+ public void acceptTrustedCertificates_createsNewKey() throws Exception {
+ final ProsperoInstallationManager mgr = new ProsperoInstallationManager(actionFactory);
+ final PGPSecretKeyRing pgpSecretKeys = TestSignatureUtils.generateSecretKey("test", "test");
+ final File testCert = temp.newFile("test.crt");
+ TestSignatureUtils.exportPublicKeys(pgpSecretKeys, testCert);
+ when(actionFactory.getCertificateAction()).thenReturn(certificateAction);
+
+ mgr.acceptTrustedCertificates(new FileInputStream(testCert));
+
+ final ArgumentCaptor captor = ArgumentCaptor.forClass(PGPPublicKey.class);
+ verify(certificateAction).importCertificate(captor.capture());
+
+ assertEquals(pgpSecretKeys.getPublicKey().getKeyID(),
+ captor.getValue().getPublicKeyRing().getPublicKey().getKeyID());
+ }
+
+ @Test
+ public void revokeTrustedCertificates_removesKey() throws Exception {
+ final ProsperoInstallationManager mgr = new ProsperoInstallationManager(actionFactory);
+ final PGPSecretKeyRing pgpSecretKeys = TestSignatureUtils.generateSecretKey("test", "test");
+ when(actionFactory.getCertificateAction()).thenReturn(certificateAction);
+
+ mgr.revokeTrustedCertificate(Long.toHexString(pgpSecretKeys.getPublicKey().getKeyID()).toUpperCase(Locale.ROOT));
+
+ final ArgumentCaptor captor = ArgumentCaptor.forClass(PGPKeyId.class);
+ verify(certificateAction).removeCertificate(captor.capture());
+
+ assertEquals(pgpSecretKeys.getPublicKey().getKeyID(),
+ captor.getValue().getKeyID());
+ }
+
+ @Test
+ public void listTrustedCertificates_listsCertificates() throws Exception {
+ final ProsperoInstallationManager mgr = new ProsperoInstallationManager(actionFactory);
+ final PGPSecretKeyRing pgpSecretKeys = TestSignatureUtils.generateSecretKey("test", "test");
+ when(actionFactory.getCertificateAction()).thenReturn(certificateAction);
+ final PGPKeyId keyID = new PGPKeyId(1L);
+ when(certificateAction.listCertificates()).thenReturn(List.of(
+ new PGPPublicKeyInfo(keyID, PGPPublicKeyInfo.Status.TRUSTED, "abcd1234", List.of("test certificate"), LocalDateTime.now(), null)
+ ));
+
+ final Collection trustCertificates = mgr.listTrustedCertificates();
+ assertThat(trustCertificates)
+ .containsExactly(new TrustCertificate(keyID.getHexKeyID(), "abcd1234", "test certificate", "TRUSTED"));
}
}
\ 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
+ );
+ }
+}
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