diff --git a/integration-test/spring-boot-loader-integration-tests/build.gradle b/integration-test/spring-boot-loader-integration-tests/build.gradle index e1204d490ad..9803aaea9e0 100644 --- a/integration-test/spring-boot-loader-integration-tests/build.gradle +++ b/integration-test/spring-boot-loader-integration-tests/build.gradle @@ -70,6 +70,19 @@ tasks.register("buildSignedJarApp", GradleBuild) { tasks = ["build"] } +tasks.register("syncSignedJarRsaAppSource", org.springframework.boot.build.SyncAppSource) { + sourceDirectory = file("spring-boot-loader-tests-signed-jar-rsa") + destinationDirectory = file(layout.buildDirectory.dir("spring-boot-loader-tests-signed-jar-rsa")) +} + +tasks.register("buildSignedJarRsaApp", GradleBuild) { + dependsOn syncSignedJarRsaAppSource, syncMavenRepository + dir = layout.buildDirectory.dir("spring-boot-loader-tests-signed-jar-rsa") + startParameter.buildCacheEnabled = false + tasks = ["build"] +} + + tasks.register("downloadJdk", Download) { def destFolder = new File(project.gradle.gradleUserHomeDir, "caches/springboot/downloads/jdk/oracle") destFolder.mkdirs() @@ -92,5 +105,5 @@ tasks.named("processDockerTestResources").configure { } tasks.named("dockerTest").configure { - dependsOn buildApp, buildSignedJarApp + dependsOn buildApp, buildSignedJarApp, buildSignedJarRsaApp } diff --git a/integration-test/spring-boot-loader-integration-tests/spring-boot-loader-tests-signed-jar-rsa/build.gradle b/integration-test/spring-boot-loader-integration-tests/spring-boot-loader-tests-signed-jar-rsa/build.gradle new file mode 100644 index 00000000000..4b559595245 --- /dev/null +++ b/integration-test/spring-boot-loader-integration-tests/spring-boot-loader-tests-signed-jar-rsa/build.gradle @@ -0,0 +1,49 @@ +/* + * Copyright 2012-present the original author or authors. + * + * 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 + * + * https://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. + */ + +import org.springframework.boot.gradle.tasks.bundling.BootJar + +plugins { + id "java" + id "org.springframework.boot" +} + +java { + sourceCompatibility = '17' + targetCompatibility = '17' +} + +repositories { + maven { url "file:${rootDir}/../docker-test-maven-repository"} + mavenCentral() + spring.mavenRepositories() +} + +dependencies { + implementation(platform(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES)) + implementation("org.springframework.boot:spring-boot-starter") + implementation("org.bouncycastle:bcprov-jdk18on:1.84") // gh-50292 +} + +tasks.register("bootJarUnpack", BootJar.class) { + mainClass = "org.springframework.boot.loaderapp.LoaderSignedJarTestApplication" + classpath = bootJar.classpath + requiresUnpack '**/bcprov-jdk18on-*.jar' + archiveClassifier.set("unpack") + targetJavaVersion = targetCompatibility +} + +build.dependsOn bootJarUnpack diff --git a/integration-test/spring-boot-loader-integration-tests/spring-boot-loader-tests-signed-jar-rsa/settings.gradle b/integration-test/spring-boot-loader-integration-tests/spring-boot-loader-tests-signed-jar-rsa/settings.gradle new file mode 100644 index 00000000000..8f2eaa44c48 --- /dev/null +++ b/integration-test/spring-boot-loader-integration-tests/spring-boot-loader-tests-signed-jar-rsa/settings.gradle @@ -0,0 +1,31 @@ +/* + * Copyright 2012-present the original author or authors. + * + * 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 + * + * https://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. + */ + +pluginManagement { + evaluate(new File("${gradle.parent.rootProject.rootDir}/buildSrc/SpringRepositorySupport.groovy")).apply(this) + repositories { + maven { url "file:${rootDir}/../docker-test-maven-repository"} + mavenCentral() + spring.mavenRepositories() + } + resolutionStrategy { + eachPlugin { + if (requested.id.id == "org.springframework.boot") { + useModule "org.springframework.boot:spring-boot-gradle-plugin:${requested.version}" + } + } + } +} \ No newline at end of file diff --git a/integration-test/spring-boot-loader-integration-tests/spring-boot-loader-tests-signed-jar-rsa/src/main/java/org/springframework/boot/loaderapp/LoaderSignedJarTestApplication.java b/integration-test/spring-boot-loader-integration-tests/spring-boot-loader-tests-signed-jar-rsa/src/main/java/org/springframework/boot/loaderapp/LoaderSignedJarTestApplication.java new file mode 100644 index 00000000000..a62d9d45070 --- /dev/null +++ b/integration-test/spring-boot-loader-integration-tests/spring-boot-loader-tests-signed-jar-rsa/src/main/java/org/springframework/boot/loaderapp/LoaderSignedJarTestApplication.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-present the original author or authors. + * + * 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 + * + * https://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.springframework.boot.loaderapp; + +import java.security.Security; +import javax.crypto.Cipher; +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class LoaderSignedJarTestApplication { + + public static void main(String[] args) throws Exception { + Security.addProvider(new BouncyCastleProvider()); + Cipher.getInstance("AES/CBC/PKCS5Padding","BC"); + System.out.println("Legion of the Bouncy Castle"); + SpringApplication.run(LoaderSignedJarTestApplication.class, args); + } + +} diff --git a/integration-test/spring-boot-loader-integration-tests/src/dockerTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java b/integration-test/spring-boot-loader-integration-tests/src/dockerTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java index d8d11546234..54d662e738a 100644 --- a/integration-test/spring-boot-loader-integration-tests/src/dockerTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java +++ b/integration-test/spring-boot-loader-integration-tests/src/dockerTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java @@ -85,6 +85,17 @@ class LoaderIntegrationTests { } } + @ParameterizedTest + @MethodSource("javaRuntimes") + void runSignedJarWhenRsa(JavaRuntime javaRuntime) { + try (GenericContainer container = createContainer(javaRuntime, "spring-boot-loader-tests-signed-jar-rsa", + null)) { + container.start(); + System.out.println(this.output.toUtf8String()); + assertThat(this.output.toUtf8String()).contains("Legion of the Bouncy Castle"); + } + } + private GenericContainer createContainer(JavaRuntime javaRuntime, String name, String classifier) { return javaRuntime.getContainer() .withLogConsumer(this.output) diff --git a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipContent.java b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipContent.java index 4013744178a..e601509f19c 100644 --- a/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipContent.java +++ b/loader/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipContent.java @@ -26,10 +26,12 @@ import java.nio.channels.ClosedChannelException; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.function.BiFunction; import java.util.function.Function; +import java.util.stream.Stream; import java.util.zip.ZipEntry; import org.springframework.boot.loader.log.DebugLogger; @@ -62,10 +64,6 @@ import org.springframework.boot.loader.log.DebugLogger; */ public final class ZipContent implements Closeable { - private static final String META_INF = "META-INF/"; - - private static final byte[] SIGNATURE_SUFFIX = ".DSA".getBytes(StandardCharsets.UTF_8); - private static final DebugLogger debug = DebugLogger.get(ZipContent.class); private static final Map cache = new ConcurrentHashMap<>(); @@ -601,26 +599,16 @@ public final class ZipContent implements Closeable { throw new IllegalStateException("Too many zip entries in " + source); } Loader loader = new Loader(source, null, data, centralDirectoryPos, (int) numberOfEntries); - ByteBuffer signatureNameSuffixBuffer = ByteBuffer.allocate(SIGNATURE_SUFFIX.length); - boolean hasJarSignatureFile = false; + SignatureFiles signatureFiles = new SignatureFiles(); long pos = centralDirectoryPos; for (int i = 0; i < numberOfEntries; i++) { ZipCentralDirectoryFileHeaderRecord centralRecord = ZipCentralDirectoryFileHeaderRecord.load(data, pos); - if (!hasJarSignatureFile) { - long filenamePos = pos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET; - if (centralRecord.fileNameLength() > SIGNATURE_SUFFIX.length && ZipString.startsWith(loader.buffer, - data, filenamePos, centralRecord.fileNameLength(), META_INF) >= 0) { - signatureNameSuffixBuffer.clear(); - data.readFully(signatureNameSuffixBuffer, - filenamePos + centralRecord.fileNameLength() - SIGNATURE_SUFFIX.length); - hasJarSignatureFile = Arrays.equals(SIGNATURE_SUFFIX, signatureNameSuffixBuffer.array()); - } - } + signatureFiles.detect(centralRecord, data, pos, loader.buffer); loader.add(centralRecord, pos, false); pos += centralRecord.size(); } long commentPos = locatedEocd.pos() + ZipEndOfCentralDirectoryRecord.COMMENT_OFFSET; - return loader.finish(kind, commentPos, eocd.commentLength(), hasJarSignatureFile); + return loader.finish(kind, commentPos, eocd.commentLength(), signatureFiles.detected()); } /** @@ -689,6 +677,54 @@ public final class ZipContent implements Closeable { } + /** + * Detection logic for signature files. + */ + private static class SignatureFiles { + + private static final String META_INF = "META-INF/"; + + private static final List SUFFIXES = Stream.of(".DSA", ".RSA", ".EC") + .map((string) -> string.getBytes(StandardCharsets.UTF_8)) + .toList(); + + private final ByteBuffer buffer; + + private boolean detected; + + SignatureFiles() { + this.buffer = ByteBuffer.allocate(4); + } + + void detect(ZipCentralDirectoryFileHeaderRecord centralRecord, FileDataBlock data, long pos, + ByteBuffer loadeBuffer) throws IOException { + if (!this.detected) { + long filenamePos = pos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET; + if (centralRecord.fileNameLength() > this.buffer.capacity() && ZipString.startsWith(loadeBuffer, data, + filenamePos, centralRecord.fileNameLength(), META_INF) >= 0) { + this.buffer.clear(); + data.readFully(this.buffer, filenamePos + centralRecord.fileNameLength() - this.buffer.capacity()); + this.detected = bufferEndsWithSignatureSuffix(); + } + } + } + + private boolean bufferEndsWithSignatureSuffix() { + byte[] bytes = this.buffer.array(); + for (byte[] suffix : SUFFIXES) { + if (Arrays.equals(bytes, bytes.length - suffix.length, bytes.length, suffix, 0, suffix.length)) { + return true; + } + } + return false; + } + + boolean detected() { + return this.detected; + } + + } + /** * A single zip content entry. */ diff --git a/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/SecurityInfoTests.java b/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/SecurityInfoTests.java index d84788befba..ac696a5baba 100644 --- a/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/SecurityInfoTests.java +++ b/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/SecurityInfoTests.java @@ -57,7 +57,7 @@ class SecurityInfoTests { @Test void getWhenHasSignatureFileButNoSecurityMaterialReturnsNone() throws Exception { File file = new File(this.temp, "test.jar"); - TestJar.create(file, false, true); + TestJar.create(file, false, "DSA"); try (ZipContent content = ZipContent.open(file.toPath())) { assertThat(content.hasJarSignatureFile()).isTrue(); SecurityInfo info = SecurityInfo.get(content); diff --git a/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/testsupport/TestJar.java b/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/testsupport/TestJar.java index d655ae62f6f..56fa3d035e0 100644 --- a/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/testsupport/TestJar.java +++ b/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/testsupport/TestJar.java @@ -28,6 +28,8 @@ import java.util.jar.Manifest; import java.util.zip.CRC32; import java.util.zip.ZipEntry; +import org.jspecify.annotations.Nullable; + /** * Support class to create or get test jars. * @@ -44,16 +46,17 @@ public abstract class TestJar { } public static void create(File file, boolean unpackNested) throws Exception { - create(file, unpackNested, false); + create(file, unpackNested, null); } - public static void create(File file, boolean unpackNested, boolean addSignatureFile) throws Exception { + public static void create(File file, boolean unpackNested, @Nullable String additionalSignedFileExtension) + throws Exception { FileOutputStream fileOutputStream = new FileOutputStream(file); try (JarOutputStream jarOutputStream = new JarOutputStream(fileOutputStream)) { jarOutputStream.setComment("outer"); writeManifest(jarOutputStream, "j1"); - if (addSignatureFile) { - writeEntry(jarOutputStream, "META-INF/some.DSA", 0); + if (additionalSignedFileExtension != null) { + writeEntry(jarOutputStream, "META-INF/some." + additionalSignedFileExtension, 0); } writeEntry(jarOutputStream, "1.dat", 1); writeEntry(jarOutputStream, "2.dat", 2); diff --git a/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipContentTests.java b/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipContentTests.java index 2f54f5ae72a..35bf12e107b 100644 --- a/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipContentTests.java +++ b/loader/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipContentTests.java @@ -381,6 +381,42 @@ class ZipContentTests { } } + @Test + void detectUnsigned() throws Exception { + File file = new File(this.tempDir, "testunsigned.jar"); + TestJar.create(file); + try (ZipContent zip = ZipContent.open(this.file.toPath())) { + assertThat(zip.hasJarSignatureFile()).isFalse(); + } + } + + @Test + void detectSignedWithDsaFile() throws Exception { + File file = new File(this.tempDir, "testsigned.jar"); + TestJar.create(file, false, "DSA"); + try (ZipContent zip = ZipContent.open(file.toPath())) { + assertThat(zip.hasJarSignatureFile()).isTrue(); + } + } + + @Test + void detectSignedWithRsaFile() throws Exception { + File file = new File(this.tempDir, "testsigned.jar"); + TestJar.create(file, false, "RSA"); + try (ZipContent zip = ZipContent.open(file.toPath())) { + assertThat(zip.hasJarSignatureFile()).isTrue(); + } + } + + @Test + void detectSignedWithEcFile() throws Exception { + File file = new File(this.tempDir, "testsigned.jar"); + TestJar.create(file, false, "EC"); + try (ZipContent zip = ZipContent.open(file.toPath())) { + assertThat(zip.hasJarSignatureFile()).isTrue(); + } + } + private File createZipFileWithEpochTimeOfZero() throws Exception { File file = new File(this.tempDir, "temp.zip"); String comment = "outer";