Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eternalcore-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import org.gradle.jvm.toolchain.JavaToolchainService

plugins {
`eternalcode-java`
`eternalcode-java-test`
`eternalcore-repositories`
`eternalcore-shadow-compiler`
`eternalcore-publish-plugin`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.eternalcode.core.loader.dependency;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

/**
* Verifies the integrity of downloaded artifacts against the checksum files published alongside them in a Maven
* repository (e.g. {@code artifact-1.0.jar.sha256}).
*
* <p>Ordered strongest-first so callers can prefer the most secure digest a repository publishes. SHA-1 is retained
* only as a last-resort fallback because it is the single digest Maven repositories are guaranteed to serve; it is
* cryptographically weak and should not be relied upon on its own.
*/
public enum Checksum {

SHA512("sha512", "SHA-512"),
SHA256("sha256", "SHA-256"),
SHA1("sha1", "SHA-1");

private final String extension;
private final String algorithm;

Checksum(String extension, String algorithm) {
this.extension = extension;
this.algorithm = algorithm;
}

/**
* The file extension appended to the artifact name to locate this checksum (without a leading dot).
*/
public String extension() {
return this.extension;
}

/**
* Computes the lower-case hex digest of the given data using this algorithm.
*/
public String hash(byte[] data) {
MessageDigest digest;
try {
digest = MessageDigest.getInstance(this.algorithm);
}
catch (NoSuchAlgorithmException exception) {
throw new DependencyException("Missing digest algorithm: " + this.algorithm, exception);
}

return toHex(digest.digest(data));
}

/**
* Returns {@code true} if the digest of {@code data} equals the published checksum.
*
* <p>Published checksum files sometimes contain trailing content such as {@code "<hash> <filename>"};
* only the leading token is compared, and comparison is case-insensitive.
*/
public boolean matches(byte[] data, String publishedChecksum) {
if (publishedChecksum == null) {
return false;
}

String expected = normalize(publishedChecksum);
if (expected.isEmpty()) {
return false;
}

return expected.equalsIgnoreCase(this.hash(data));
}

static String normalize(String rawChecksum) {
String trimmed = rawChecksum.trim();

for (int index = 0; index < trimmed.length(); index++) {
if (Character.isWhitespace(trimmed.charAt(index))) {
return trimmed.substring(0, index);
}
}

return trimmed;
}

private static String toHex(byte[] bytes) {
StringBuilder builder = new StringBuilder(bytes.length * 2);

for (byte value : bytes) {
builder.append(Character.forDigit((value >> 4) & 0xF, 16));
builder.append(Character.forDigit(value & 0xF, 16));
}

return builder.toString();
}
Comment on lines +81 to +90

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since the project is using Java 25, we can leverage the standard java.util.HexFormat utility introduced in Java 17 instead of implementing a custom hex conversion method. This simplifies the codebase, improves maintainability, and reduces the risk of bugs in custom bit-manipulation logic.

    private static String toHex(byte[] bytes) {
        return java.util.HexFormat.of().formatHex(bytes);
    }


}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ public ResourceLocator toMavenJar(Repository repository, String classifier) {
return toResource(repository, JAR_MAVEN_FORMAT_WITH_CLASSIFIER.formatted(this.artifactId, this.version, classifier));
}

public ResourceLocator toMavenJarChecksum(Repository repository, String checksumExtension) {
String jarName = JAR_MAVEN_FORMAT.formatted(this.artifactId, this.version);
return toResource(repository, jarName + "." + checksumExtension);
}

public ResourceLocator toPomXml(Repository repository) {
return toResource(repository, POM_XML_FORMAT.formatted(this.artifactId, this.version));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import java.io.InputStream;
import java.net.URISyntaxException;
import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
Expand All @@ -18,6 +19,9 @@

public class DependencyDownloader {

private static final int CONNECT_TIMEOUT_MILLIS = 15_000;
private static final int READ_TIMEOUT_MILLIS = 30_000;

private final Logger logger;
private final Repository localRepository;
private final List<Repository> repositories;
Expand Down Expand Up @@ -70,6 +74,8 @@ private Path tryDownloadDependency(Dependency dependency) throws URISyntaxExcept
private Path downloadJarAndSave(Repository repository, Dependency dependency, Path file) {
try {
byte[] bytes = this.downloadJar(repository, dependency);
this.verifyChecksum(repository, dependency, bytes);

Path parent = file.getParent();

Files.createDirectories(parent);
Expand All @@ -86,9 +92,7 @@ private Path downloadJarAndSave(Repository repository, Dependency dependency, Pa
}

private byte[] downloadJar(Repository repository, Dependency dependency) throws IOException {
URLConnection connection = dependency.toMavenJar(repository).toURL().openConnection();

try (InputStream in = connection.getInputStream()) {
try (InputStream in = openStream(dependency.toMavenJar(repository).toURL().openConnection())) {
byte[] bytes = ByteStreams.toByteArray(in);

if (bytes.length == 0) {
Expand All @@ -99,4 +103,53 @@ private byte[] downloadJar(Repository repository, Dependency dependency) throws
}
}

/**
* Verifies the downloaded artifact against the strongest checksum the repository publishes for it. Fails closed:
* a mismatch, or the total absence of any published checksum, rejects this repository so the caller can fall back
* to another one (and ultimately fail if none can vouch for the artifact). This prevents a tampered or corrupted
* jar from being written to the local cache and loaded into the JVM.
*/
private void verifyChecksum(Repository repository, Dependency dependency, byte[] jarBytes) {
for (Checksum checksum : Checksum.values()) {
String publishedChecksum = this.downloadChecksum(repository, dependency, checksum);

if (publishedChecksum == null) {
continue;
}

if (!checksum.matches(jarBytes, publishedChecksum)) {
throw new DependencyException(
"Checksum mismatch (" + checksum.extension() + ") for " + dependency + " from " + repository
+ " - refusing to load a potentially tampered dependency");
}

return;
}

throw new DependencyException(
"No published checksum (sha512/sha256/sha1) available to verify " + dependency + " from " + repository);
}

private String downloadChecksum(Repository repository, Dependency dependency, Checksum checksum) {
try (InputStream in = openStream(dependency.toMavenJarChecksum(repository, checksum.extension()).toURL().openConnection())) {
byte[] bytes = ByteStreams.toByteArray(in);
Comment on lines +134 to +135

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

To prevent potential OutOfMemoryError or Denial of Service (DoS) attacks from a malicious or misconfigured repository serving an excessively large or infinite stream for the checksum file, it is highly recommended to limit the number of bytes read from the input stream. Since a valid checksum is always very small (typically under 200 bytes), we can safely limit the download to 1024 bytes using ByteStreams.limit.

Suggested change
try (InputStream in = openStream(dependency.toMavenJarChecksum(repository, checksum.extension()).toURL().openConnection())) {
byte[] bytes = ByteStreams.toByteArray(in);
try (InputStream in = openStream(dependency.toMavenJarChecksum(repository, checksum.extension()).toURL().openConnection())) {
byte[] bytes = ByteStreams.toByteArray(ByteStreams.limit(in, 1024));


if (bytes.length == 0) {
return null;
}

return new String(bytes, StandardCharsets.UTF_8);
}
catch (IOException exception) {
// Checksum not served by this repository; the caller treats this as "cannot verify here".
return null;
}
}

private static InputStream openStream(URLConnection connection) throws IOException {
connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS);
connection.setReadTimeout(READ_TIMEOUT_MILLIS);
return connection.getInputStream();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import java.io.InputStream;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
Expand All @@ -25,6 +26,9 @@

public class PomXmlScanner implements DependencyScanner {

private static final int CONNECT_TIMEOUT_MILLIS = 15_000;
private static final int READ_TIMEOUT_MILLIS = 30_000;

private static final DocumentBuilderFactory DOCUMENT_BUILDER_FACTORY = DocumentBuilderFactory.newInstance();

static {
Expand Down Expand Up @@ -91,7 +95,11 @@ private File savePomXmlToLocalRepository(Dependency dependency, Repository repos

URL url = dependency.toPomXml(repository).toURL();

try (InputStream inputStream = url.openStream()) {
URLConnection connection = url.openConnection();
connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS);
connection.setReadTimeout(READ_TIMEOUT_MILLIS);

try (InputStream inputStream = connection.getInputStream()) {
Files.createDirectories(localFile.toPath());
Files.copy(inputStream, localFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.eternalcode.core.loader.dependency;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;

class ChecksumTest {

// Known digests of the ASCII string "abc".
private static final byte[] ABC = "abc".getBytes(StandardCharsets.UTF_8);
private static final String ABC_SHA1 = "a9993e364706816aba3e25717850c26c9cd0d89d";
private static final String ABC_SHA256 = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
private static final String ABC_SHA512 =
"ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a"
+ "2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f";

@Test
void computesKnownDigests() {
assertEquals(ABC_SHA1, Checksum.SHA1.hash(ABC));
assertEquals(ABC_SHA256, Checksum.SHA256.hash(ABC));
assertEquals(ABC_SHA512, Checksum.SHA512.hash(ABC));
}

@Test
void matchesIgnoringCase() {
assertTrue(Checksum.SHA256.matches(ABC, ABC_SHA256));
assertTrue(Checksum.SHA256.matches(ABC, ABC_SHA256.toUpperCase()));
}

@Test
void rejectsMismatchedChecksum() {
assertFalse(Checksum.SHA256.matches(ABC, ABC_SHA1));
assertFalse(Checksum.SHA256.matches(ABC, "not-a-hash"));
}

@Test
void rejectsNullOrBlankChecksum() {
assertFalse(Checksum.SHA256.matches(ABC, null));
assertFalse(Checksum.SHA256.matches(ABC, " "));
}

@Test
void ignoresTrailingFilenameInPublishedChecksum() {
// Maven repositories occasionally publish "<hash> <filename>".
assertTrue(Checksum.SHA256.matches(ABC, ABC_SHA256 + " artifact-1.0.jar"));
assertEquals(ABC_SHA256, Checksum.normalize(ABC_SHA256 + " artifact-1.0.jar"));
}

@Test
void exposesExpectedExtensions() {
assertEquals("sha512", Checksum.SHA512.extension());
assertEquals("sha256", Checksum.SHA256.extension());
assertEquals("sha1", Checksum.SHA1.extension());
}
}
Loading