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