Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 thread
Jakubk15 marked this conversation as resolved.

}
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 thread
Jakubk15 marked this conversation as resolved.
Outdated

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