diff --git a/CHANGELOG.md b/CHANGELOG.md index 352a61d24..0840b3848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ see wiki for more information: [wiki](https://github.com/thmarx/cms/wiki) * **FEATURE** Developer Experience [PR-440](https://github.com/CondationCMS/cms-server/pull/440) * **FEATURE** Aliases for content [442](https://github.com/CondationCMS/cms-server/issues/442) * **FEATURE** Add redirect support for aliases [454](https://github.com/CondationCMS/cms-server/issues/454) +* **FEATURE** Signature for modules and themes [471](https://github.com/CondationCMS/cms-server/issues/471) * **FEATURE** Switch password has to secure algorithm [472](https://github.com/CondationCMS/cms-server/issues/472) ### Developer experience diff --git a/cms-core/src/main/java/com/condation/cms/core/utils/HashVerifier.java b/cms-core/src/main/java/com/condation/cms/core/utils/HashVerifier.java new file mode 100644 index 000000000..d514823b7 --- /dev/null +++ b/cms-core/src/main/java/com/condation/cms/core/utils/HashVerifier.java @@ -0,0 +1,58 @@ +package com.condation.cms.core.utils; + +/*- + * #%L + * cms-core + * %% + * Copyright (C) 2023 - 2025 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ + +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class HashVerifier { + + public static String calculateSHA256(Path filePath) throws IOException, NoSuchAlgorithmException { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (FileInputStream fis = new FileInputStream(filePath.toFile())) { + byte[] buffer = new byte[8192]; + int read; + while ((read = fis.read(buffer)) != -1) { + digest.update(buffer, 0, read); + } + } + byte[] hashBytes = digest.digest(); + return bytesToHex(hashBytes); + } + + public static boolean verifySHA256(Path filePath, String expectedHash) throws IOException, NoSuchAlgorithmException { + String actualHash = calculateSHA256(filePath); + return actualHash.equalsIgnoreCase(expectedHash); + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder result = new StringBuilder(); + for (byte b : bytes) { + result.append(String.format("%02x", b)); + } + return result.toString(); + } +} diff --git a/cms-extensions/pom.xml b/cms-extensions/pom.xml index de8fb773d..5b522fbe4 100644 --- a/cms-extensions/pom.xml +++ b/cms-extensions/pom.xml @@ -15,6 +15,10 @@ com.condation.cms cms-api + + com.condation.cms + cms-core + com.condation.cms cms-filesystem diff --git a/cms-extensions/src/main/java/com/condation/cms/extensions/repository/ModuleInfo.java b/cms-extensions/src/main/java/com/condation/cms/extensions/repository/ModuleInfo.java index abbb10143..466fb3c5c 100644 --- a/cms-extensions/src/main/java/com/condation/cms/extensions/repository/ModuleInfo.java +++ b/cms-extensions/src/main/java/com/condation/cms/extensions/repository/ModuleInfo.java @@ -40,4 +40,5 @@ public class ModuleInfo { private String url; private String compatibility; private String file; + private String signature; } diff --git a/cms-extensions/src/main/java/com/condation/cms/extensions/repository/RemoteModuleRepository.java b/cms-extensions/src/main/java/com/condation/cms/extensions/repository/RemoteModuleRepository.java index 137fd80f2..1d92dd45d 100644 --- a/cms-extensions/src/main/java/com/condation/cms/extensions/repository/RemoteModuleRepository.java +++ b/cms-extensions/src/main/java/com/condation/cms/extensions/repository/RemoteModuleRepository.java @@ -21,6 +21,7 @@ * . * #L% */ +import com.condation.cms.core.utils.HashVerifier; import java.io.File; import java.io.IOException; import java.net.URI; @@ -84,7 +85,7 @@ public Optional getInfo(String extension) { return Optional.empty(); } - public void download(String url, Path target) { + public void download(String url, String signature, Path target) { try { Path tempDirectory = Files.createTempDirectory("modules"); if (SystemUtils.IS_OS_UNIX) { @@ -102,6 +103,11 @@ public void download(String url, Path target) { HttpResponse.BodyHandlers.ofFile(tempDirectory.resolve(System.currentTimeMillis() + ".zip"))); Path downloaded = response.body(); + + if (!HashVerifier.verifySHA256(downloaded, signature)) { + throw new RuntimeException("sinature does not match"); + } + File moduleTempDir = InstallationHelper.unpackArchive(downloaded.toFile(), tempDirectory.toFile()); InstallationHelper.moveDirectoy(moduleTempDir, target.resolve(moduleTempDir.getName()).toFile()); diff --git a/cms-extensions/src/main/java/com/condation/cms/extensions/repository/ThemeInfo.java b/cms-extensions/src/main/java/com/condation/cms/extensions/repository/ThemeInfo.java index 5f712560b..ed7857b81 100644 --- a/cms-extensions/src/main/java/com/condation/cms/extensions/repository/ThemeInfo.java +++ b/cms-extensions/src/main/java/com/condation/cms/extensions/repository/ThemeInfo.java @@ -40,4 +40,5 @@ public class ThemeInfo { private String url; private String compatibility; private String file; + private String signature; } diff --git a/cms-extensions/src/test/java/com/condation/cms/extensions/repository/SignaturedRemoteModuleRepositoryTest.java b/cms-extensions/src/test/java/com/condation/cms/extensions/repository/SignaturedRemoteModuleRepositoryTest.java new file mode 100644 index 000000000..fd2184421 --- /dev/null +++ b/cms-extensions/src/test/java/com/condation/cms/extensions/repository/SignaturedRemoteModuleRepositoryTest.java @@ -0,0 +1,139 @@ +package com.condation.cms.extensions.repository; + +/*- + * #%L + * cms-extensions + * %% + * Copyright (C) 2023 - 2025 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program. If not, see + * . + * #L% + */ +import com.sun.net.httpserver.HttpServer; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.*; + +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.zip.ZipEntry; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SignaturedRemoteModuleRepositoryTest { + + private HttpServer fileServer; + + private String fileBaseUrl; + + private String hash; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class DummyExtensionInfo { + + String name; + String version; + String file; + String sinature; + } + + @BeforeAll + void setupServers() throws Exception { + fileServer = HttpServer.create(new InetSocketAddress(0), 0); + + int filePort = fileServer.getAddress().getPort(); + + fileBaseUrl = "http://localhost:" + filePort + "/files"; + + // Erstelle eine ZIP-Datei mit Dummy-Inhalt + Path tempDir = Files.createTempDirectory("test-zip"); + Path contentFile = tempDir.resolve("content.txt"); + Files.writeString(contentFile, "test content"); + + Path zipFile = Files.createTempFile("test", ".zip"); + try (var zip = new java.util.zip.ZipOutputStream(Files.newOutputStream(zipFile))) { + // Verzeichnis explizit anlegen + var dirEntry = new ZipEntry("test-module/"); + zip.putNextEntry(dirEntry); + zip.closeEntry(); + + // Datei im Verzeichnis ablegen + var fileEntry = new ZipEntry("test-module/content.txt"); + zip.putNextEntry(fileEntry); + zip.write("test content".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + + // Berechne den Hash der Datei + hash = com.condation.cms.core.utils.HashVerifier.calculateSHA256(zipFile); + + // Statische URL für den Download + fileServer.createContext("/files/test.zip", exchange -> { + byte[] data = Files.readAllBytes(zipFile); + exchange.sendResponseHeaders(200, data.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(data); + } + }); + + fileServer.start(); + } + + @AfterAll + void tearDown() { + fileServer.stop(0); + } + + @Test + void shouldDownloadAndVerifyFileSignature() throws Exception { + + // Zielverzeichnis + Path installTarget = Files.createTempDirectory("install-target"); + + var repo = new RemoteModuleRepository<>(DummyExtensionInfo.class, List.of()); + + // Führe Download und Verifikation durch + repo.download(fileBaseUrl + "/test.zip", hash, installTarget); + + // Prüfung, ob Datei im entpackten Verzeichnis vorhanden ist + boolean found = Files.walk(installTarget) + .anyMatch(p -> p.getFileName().toString().equals("content.txt")); + + Assertions.assertThat(found) + .as("content.txt sollte entpackt vorhanden sein") + .isTrue(); + } + + @Test + void shouldThrowAnExceptionOnInvalidSignature() throws Exception { + + // Zielverzeichnis + Path installTarget = Files.createTempDirectory("install-target"); + + var repo = new RemoteModuleRepository<>(DummyExtensionInfo.class, List.of()); + + Assertions.assertThatCode(() -> { + repo.download(fileBaseUrl + "/test.zip", "wrong_signature", installTarget); + }).isInstanceOf(RuntimeException.class).hasMessage("error downloading module"); + + } +} diff --git a/cms-server/src/main/java/com/condation/cms/cli/commands/modules/GetAllCommand.java b/cms-server/src/main/java/com/condation/cms/cli/commands/modules/GetAllCommand.java index 5bfd2c4bf..7f2e4700f 100644 --- a/cms-server/src/main/java/com/condation/cms/cli/commands/modules/GetAllCommand.java +++ b/cms-server/src/main/java/com/condation/cms/cli/commands/modules/GetAllCommand.java @@ -90,7 +90,7 @@ private boolean installModule(String module) { var info = getRepository().getInfo(module).get(); System.out.printf("get module %s \r\n", module); - getRepository().download(info.getFile(), ServerUtil.getPath("modules/")); + getRepository().download(info.getFile(), info.getSignature(), ServerUtil.getPath("modules/")); System.out.println("module downloaded"); return true; } else { diff --git a/cms-server/src/main/java/com/condation/cms/cli/commands/modules/GetCommand.java b/cms-server/src/main/java/com/condation/cms/cli/commands/modules/GetCommand.java index ef4b6ad68..0f8a2a234 100644 --- a/cms-server/src/main/java/com/condation/cms/cli/commands/modules/GetCommand.java +++ b/cms-server/src/main/java/com/condation/cms/cli/commands/modules/GetCommand.java @@ -86,7 +86,7 @@ public void run() { var info = getRepository().getInfo(module).get(); System.out.printf("get module %s \r\n", module); - getRepository().download(info.getFile(), ServerUtil.getPath(Constants.Folders.MODULES)); + getRepository().download(info.getFile(), info.getSignature(), ServerUtil.getPath(Constants.Folders.MODULES)); System.out.println("module downloaded"); } else { System.out.printf("can not find module %s in registry", module); diff --git a/cms-server/src/main/java/com/condation/cms/cli/commands/themes/GetAllCommand.java b/cms-server/src/main/java/com/condation/cms/cli/commands/themes/GetAllCommand.java index 14649ed1a..66d440ed9 100644 --- a/cms-server/src/main/java/com/condation/cms/cli/commands/themes/GetAllCommand.java +++ b/cms-server/src/main/java/com/condation/cms/cli/commands/themes/GetAllCommand.java @@ -28,8 +28,6 @@ import com.condation.cms.cli.tools.ThemesUtil; import com.condation.cms.extensions.repository.InstallationHelper; -import com.google.common.base.Strings; -import java.nio.file.Path; import java.util.Set; import lombok.extern.slf4j.Slf4j; import picocli.CommandLine; @@ -75,7 +73,7 @@ public void getTheme(String theme) { var info = getRepository().getInfo(theme).get(); System.out.println("get theme"); - getRepository().download(info.getFile(), ServerUtil.getPath(Constants.Folders.THEMES)); + getRepository().download(info.getFile(), info.getSignature(), ServerUtil.getPath(Constants.Folders.THEMES)); System.out.println("theme downloaded"); } diff --git a/cms-server/src/main/java/com/condation/cms/cli/commands/themes/GetCommand.java b/cms-server/src/main/java/com/condation/cms/cli/commands/themes/GetCommand.java index 0585c3ac9..a314c788b 100644 --- a/cms-server/src/main/java/com/condation/cms/cli/commands/themes/GetCommand.java +++ b/cms-server/src/main/java/com/condation/cms/cli/commands/themes/GetCommand.java @@ -79,7 +79,7 @@ public void run() { var info = getRepository().getInfo(theme).get(); System.out.println("get theme"); - getRepository().download(info.getFile(), ServerUtil.getPath(Constants.Folders.THEMES)); + getRepository().download(info.getFile(), info.getSignature(), ServerUtil.getPath(Constants.Folders.THEMES)); System.out.println("theme downloaded"); }