Verify the minecraft jar signature (#1282)

* Verify the minecraft jar cert

* Don't verify old server jars.

* Checkstyle

* Unit test fixes

* Add a list of known version hashes for versions that we cannot verify the jar signature.

Either the versions arent signed, or are signed with a SHA-1.

* Only verify minecraft jars when they were actually downloaded again.

* Add property to disable verification

* Fix import

* Fix bundled jars
This commit is contained in:
modmuss
2025-04-07 11:53:55 +01:00
committed by GitHub
parent dbe1408a72
commit 186b774a2e
31 changed files with 2402 additions and 33 deletions

View File

@@ -93,7 +93,6 @@ public final class MergedMinecraftProvider extends MinecraftProvider {
File minecraftServerJar = getMinecraftServerJar();
if (getServerBundleMetadata() != null) {
extractBundledServerJar();
minecraftServerJar = getMinecraftExtractedServerJar();
}

View File

@@ -29,6 +29,7 @@ import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import com.google.common.base.Preconditions;
import org.gradle.api.JavaVersion;
@@ -41,9 +42,12 @@ import net.fabricmc.loom.LoomGradleExtension;
import net.fabricmc.loom.api.mappings.layered.MappingsNamespace;
import net.fabricmc.loom.configuration.ConfigContext;
import net.fabricmc.loom.configuration.providers.BundleMetadata;
import net.fabricmc.loom.configuration.providers.minecraft.verify.MinecraftJarVerification;
import net.fabricmc.loom.configuration.providers.minecraft.verify.SignatureVerificationFailure;
import net.fabricmc.loom.util.Constants;
import net.fabricmc.loom.util.download.DownloadExecutor;
import net.fabricmc.loom.util.download.GradleDownloadProgressListener;
import net.fabricmc.loom.util.gradle.GradleUtils;
import net.fabricmc.loom.util.gradle.ProgressGroup;
public abstract class MinecraftProvider {
@@ -88,10 +92,18 @@ public abstract class MinecraftProvider {
}
}
downloadJars();
boolean didDownload = downloadJars();
if (provideServer()) {
serverBundleMetadata = BundleMetadata.fromJar(minecraftServerJar.toPath());
if (serverBundleMetadata != null) {
extractBundledServerJar();
}
}
if (didDownload) {
verifyJars();
}
final MinecraftLibraryProvider libraryProvider = new MinecraftLibraryProvider(this, configContext.project());
@@ -109,7 +121,34 @@ public abstract class MinecraftProvider {
}
}
private void downloadJars() throws IOException {
private void verifyJars() throws IOException, SignatureVerificationFailure {
if (GradleUtils.getBooleanProperty(getProject(), Constants.Properties.DISABLE_MINECRAFT_VERIFICATION)) {
LOGGER.info("Skipping Minecraft jar verification!");
}
LOGGER.info("Verifying Minecraft jars");
MinecraftJarVerification verification = getProject().getObjects().newInstance(MinecraftJarVerification.class, minecraftVersion());
if (provideClient()) {
verification.verifyClientJar(minecraftClientJar.toPath());
}
if (provideServer()) {
if (serverBundleMetadata == null) {
verification.verifyServerJar(minecraftServerJar.toPath());
} else {
verification.verifyServerJar(getMinecraftExtractedServerJar().toPath());
}
}
LOGGER.info("Jar verification complete");
}
// Returns true when a file was downloaded
private boolean downloadJars() throws IOException {
AtomicBoolean didDownload = new AtomicBoolean(false);
try (ProgressGroup progressGroup = new ProgressGroup(getProject(), "Download Minecraft jars");
DownloadExecutor executor = new DownloadExecutor(2)) {
if (provideClient()) {
@@ -117,7 +156,12 @@ public abstract class MinecraftProvider {
getExtension().download(client.url())
.sha1(client.sha1())
.progress(new GradleDownloadProgressListener("Minecraft client", progressGroup::createProgressLogger))
.downloadPathAsync(minecraftClientJar.toPath(), executor);
.downloadPathAsync(minecraftClientJar.toPath(), executor)
.thenAccept(downloadResult -> {
if (downloadResult.didDownload()) {
didDownload.set(true);
}
});
}
if (provideServer()) {
@@ -125,12 +169,25 @@ public abstract class MinecraftProvider {
getExtension().download(server.url())
.sha1(server.sha1())
.progress(new GradleDownloadProgressListener("Minecraft server", progressGroup::createProgressLogger))
.downloadPathAsync(minecraftServerJar.toPath(), executor);
.downloadPathAsync(minecraftServerJar.toPath(), executor)
.thenAccept(downloadResult -> {
if (downloadResult.didDownload()) {
didDownload.set(true);
}
});
}
}
if (didDownload.get()) {
LOGGER.info("Downloaded new Minecraft jars");
return true;
}
LOGGER.info("Using cached Minecraft jars");
return false;
}
protected final void extractBundledServerJar() throws IOException {
private void extractBundledServerJar() throws IOException {
Preconditions.checkArgument(provideServer(), "Not configured to provide server jar");
Objects.requireNonNull(getServerBundleMetadata(), "Cannot bundled mc jar from none bundled server jar");

View File

@@ -139,14 +139,13 @@ public abstract sealed class SingleJarMinecraftProvider extends MinecraftProvide
}
@Override
public Path getInputJar(SingleJarMinecraftProvider provider) throws Exception {
public Path getInputJar(SingleJarMinecraftProvider provider) {
BundleMetadata serverBundleMetadata = provider.getServerBundleMetadata();
if (serverBundleMetadata == null) {
return provider.getMinecraftServerJar().toPath();
}
provider.extractBundledServerJar();
return provider.getMinecraftExtractedServerJar().toPath();
}

View File

@@ -74,8 +74,6 @@ public final class SplitMinecraftProvider extends MinecraftProvider {
throw new UnsupportedOperationException("Only Minecraft versions using a bundled server jar can be split, please use a merged jar setup for this version of minecraft");
}
extractBundledServerJar();
final Path clientJar = getMinecraftClientJar().toPath();
final Path serverJar = getMinecraftExtractedServerJar().toPath();

View File

@@ -0,0 +1,194 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2025 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.configuration.providers.minecraft.verify;
import java.io.IOException;
import java.io.InputStream;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.jetbrains.annotations.Nullable;
/**
* A node in the certificate chain.
*/
public interface CertificateChain {
/**
* The certificate itself.
*/
X509Certificate certificate();
/**
* The issuer of this certificate, or null if this is a root certificate.
*/
@Nullable CertificateChain issuer();
/**
* The children of this certificate, or an empty list if this is a leaf certificate.
*/
List<CertificateChain> children();
/**
* Verify that this certificate chain matches exactly with another one.
* @param other the other certificate chain
*/
void verifyChainMatches(CertificateChain other) throws SignatureVerificationFailure;
/**
* Recursively visit all certificates in the chain, including this one.
*/
static void visitAll(CertificateChain chain, CertificateConsumer consumer) throws SignatureVerificationFailure {
consumer.accept(chain.certificate());
for (CertificateChain child : chain.children()) {
visitAll(child, consumer);
}
}
/**
* Load certificate chain from the classpath, returning the root certificate.
*/
static CertificateChain getRoot(String name) throws IOException {
try (InputStream is = JarVerifier.class.getClassLoader().getResourceAsStream("certs/" + name + ".cer")) {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
Collection<X509Certificate> certificates = cf.generateCertificates(is).stream()
.map(c -> (X509Certificate) c)
.toList();
return getRoot(certificates);
} catch (CertificateException e) {
throw new RuntimeException("Failed to load certificate: " + name, e);
}
}
/**
* Takes an unordered collection of certificates and builds a tree structure.
*/
static CertificateChain getRoot(Collection<X509Certificate> certificates) {
Map<String, Impl> certificateNodes = new HashMap<>();
for (X509Certificate certificate : certificates) {
Impl node = new Impl();
node.certificate = certificate;
certificateNodes.put(certificate.getSubjectX500Principal().getName(), node);
}
for (X509Certificate certificate : certificates) {
String subject = certificate.getSubjectX500Principal().getName();
String issuer = certificate.getIssuerX500Principal().getName();
if (subject.equals(issuer)) {
continue; // self-signed
}
Impl parent = certificateNodes.get(issuer);
Impl self = certificateNodes.get(subject);
if (parent == self) {
throw new IllegalStateException("Certificate " + subject + " is its own issuer");
}
if (parent == null) {
throw new IllegalStateException("Certificate " + subject + " defines issuer " + issuer + " which is not in the chain");
}
parent.children.add(self);
self.issuer = parent;
}
List<Impl> roots = certificateNodes.values()
.stream()
.filter(node -> node.issuer == null)
.toList();
if (roots.size() != 1) {
throw new IllegalStateException("Expected exactly one root certificate, but found " + roots.size());
}
return roots.get(0);
}
@FunctionalInterface
interface CertificateConsumer {
void accept(X509Certificate certificate) throws SignatureVerificationFailure;
}
class Impl implements CertificateChain {
X509Certificate certificate;
@Nullable CertificateChain.Impl issuer;
List<CertificateChain> children = new ArrayList<>();
private Impl() {
}
@Override
public X509Certificate certificate() {
return certificate;
}
@Override
public @Nullable CertificateChain issuer() {
return issuer;
}
@Override
public List<CertificateChain> children() {
return children;
}
@Override
public void verifyChainMatches(CertificateChain other) throws SignatureVerificationFailure {
if (!this.certificate().equals(other.certificate())) {
throw new SignatureVerificationFailure("Certificate mismatch: " + this + " != " + other);
}
if (this.children().size() != other.children().size()) {
throw new SignatureVerificationFailure("Certificate mismatch: " + this + " has " + this.children().size() + " children, but " + other + " has " + other.children().size());
}
if (this.children.isEmpty()) {
// Fine, leaf certificate
return;
}
if (this.children.size() != 1) {
// TODO support this, not needed currently
throw new UnsupportedOperationException("Validating Certificate chain with multiple children is not supported");
}
this.children.get(0).verifyChainMatches(other.children().get(0));
}
@Override
public String toString() {
return certificate.getSubjectX500Principal().getName();
}
}
}

View File

@@ -0,0 +1,122 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2025 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.configuration.providers.minecraft.verify;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.cert.CRLException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509CRL;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.gradle.api.Project;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.fabricmc.loom.LoomGradleExtension;
import net.fabricmc.loom.util.download.DownloadException;
public record CertificateRevocationList(Collection<X509CRL> crls, boolean downloadFailure) {
/**
* Hardcoded CRLs for Mojang's certificate, we don't want to add a large dependency just to parse this each time.
*/
public static final List<String> CSC3_2010 = List.of(
"http://crl.verisign.com/pca3-g5.crl",
"http://crl.verisign.com/pca3.crl",
"http://csc3-2010-crl.verisign.com/CSC3-2010.crl"
);
private static final Logger LOGGER = LoggerFactory.getLogger(CertificateRevocationList.class);
/**
* Attempt to download the CRL from the given URL, if we fail to get it its not the end of the world.
*/
public static CertificateRevocationList create(Project project, List<String> urls) throws IOException {
List<X509CRL> crls = new ArrayList<>();
boolean downloadFailure = false;
for (String url : urls) {
try {
crls.add(download(project, url));
} catch (DownloadException e) {
LOGGER.warn("Failed to download CRL from {}: {}", url, e.getMessage());
LOGGER.warn("Loom will not be able to verify the integrity of the minecraft jar");
downloadFailure = true;
}
}
return new CertificateRevocationList(crls, downloadFailure);
}
static X509CRL download(Project project, String url) throws IOException {
final LoomGradleExtension extension = LoomGradleExtension.get(project);
final String name = url.substring(url.lastIndexOf('/') + 1);
final Path path = extension.getFiles().getUserCache().toPath()
.resolve("crl")
.resolve(name);
LOGGER.info("Downloading CRL from {} to {}", url, path);
extension.download(url)
.allowInsecureProtocol()
.maxAge(Duration.ofDays(7)) // Cache the CRL for a week
.downloadPath(path);
return parse(path);
}
static X509CRL parse(Path path) throws IOException {
try (InputStream inStream = Files.newInputStream(path)) {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
return (X509CRL) cf.generateCRL(inStream);
} catch (CRLException | CertificateException e) {
throw new RuntimeException(e);
}
}
/**
* Verify that none of the certs in the chain are revoked.
* @throws SignatureVerificationFailure if the certificate is revoked
*/
public void verify(CertificateChain certificateChain) throws SignatureVerificationFailure {
CertificateChain.visitAll(certificateChain, this::verify);
}
private void verify(X509Certificate certificate) throws SignatureVerificationFailure {
for (X509CRL crl : crls) {
if (crl.isRevoked(certificate)) {
throw new SignatureVerificationFailure("Certificate " + certificate.getSubjectX500Principal().getName() + " is revoked");
}
}
}
}

View File

@@ -0,0 +1,92 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2025 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.configuration.providers.minecraft.verify;
import java.io.IOException;
import java.nio.file.Path;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.fabricmc.loom.util.ZipReprocessorUtil;
public final class JarVerifier {
private static final Logger LOGGER = LoggerFactory.getLogger(JarVerifier.class);
private JarVerifier() {
}
public static void verify(Path jarPath, CertificateChain certificateChain) throws IOException, SignatureVerificationFailure {
Objects.requireNonNull(jarPath, "jarPath");
Objects.requireNonNull(certificateChain, "certificateChain");
if (certificateChain.issuer() != null) {
throw new IllegalStateException("Can only verify jars from a root certificate");
}
Set<X509Certificate> jarCertificates = new HashSet<>();
try (JarFile jarFile = new JarFile(jarPath.toFile(), true)) {
for (JarEntry jarEntry : Collections.list(jarFile.entries())) {
if (ZipReprocessorUtil.isSpecialFile(jarEntry.getName())
|| jarEntry.getName().equals("META-INF/MANIFEST.MF")
|| jarEntry.isDirectory()) {
continue;
}
try {
// Must read the entire entry to trigger the signature verification
byte[] bytes = jarFile.getInputStream(jarEntry).readAllBytes();
} catch (SecurityException e) {
throw new SignatureVerificationFailure("Jar entry " + jarEntry.getName() + " failed signature verification", e);
}
Certificate[] entryCertificates = jarEntry.getCertificates();
if (entryCertificates == null) {
throw new SignatureVerificationFailure("Jar entry " + jarEntry.getName() + " does not have a signature");
}
Arrays.stream(entryCertificates)
.map(c -> (X509Certificate) c)
.forEach(jarCertificates::add);
}
}
CertificateChain jarCertificateChain = CertificateChain.getRoot(jarCertificates);
jarCertificateChain.verifyChainMatches(certificateChain);
LOGGER.debug("Jar {} is signed by the expected certificate", jarPath);
}
}

View File

@@ -0,0 +1,57 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2025 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.configuration.providers.minecraft.verify;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.UncheckedIOException;
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
import com.google.common.base.Suppliers;
import net.fabricmc.loom.LoomGradlePlugin;
/**
* The know versions keep track of the versions that are signed using SHA1 or not signature at all.
* The maps are the Minecraft version to sha256 hash of the jar file.
*/
public record KnownVersions(
Map<String, String> client,
Map<String, String> server) {
public static final Supplier<KnownVersions> INSTANCE = Suppliers.memoize(KnownVersions::load);
private static KnownVersions load() {
try (InputStream is = KnownVersions.class.getClassLoader().getResourceAsStream("certs/known_versions.json");
Reader reader = new InputStreamReader(Objects.requireNonNull(is))) {
return LoomGradlePlugin.GSON.fromJson(reader, KnownVersions.class);
} catch (IOException e) {
throw new UncheckedIOException("Failed to load known versions", e);
}
}
}

View File

@@ -0,0 +1,113 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2025 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.configuration.providers.minecraft.verify;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import javax.inject.Inject;
import com.google.common.base.Function;
import org.gradle.api.Project;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.fabricmc.loom.util.Checksum;
public abstract class MinecraftJarVerification {
private static final Logger LOGGER = LoggerFactory.getLogger(MinecraftJarVerification.class);
private final String minecraftVersion;
@Inject
protected abstract Project getProject();
@Inject
public MinecraftJarVerification(String minecraftVersion) {
this.minecraftVersion = minecraftVersion;
}
public void verifyClientJar(Path path) throws IOException, SignatureVerificationFailure {
verifyJarSignature(path, KnownJarType.CLIENT);
}
public void verifyServerJar(Path path) throws IOException, SignatureVerificationFailure {
verifyJarSignature(path, KnownJarType.SERVER);
}
private void verifyJarSignature(Path path, KnownJarType type) throws IOException, SignatureVerificationFailure {
CertificateChain chain = CertificateChain.getRoot("mojangcs");
CertificateRevocationList revocationList = CertificateRevocationList.create(getProject(), CertificateRevocationList.CSC3_2010);
try {
revocationList.verify(chain);
JarVerifier.verify(path, chain);
} catch (SignatureVerificationFailure e) {
if (isValidKnownVersion(path, minecraftVersion, type)) {
LOGGER.info("Minecraft {} signature verification failed, but is a known version", path.getFileName());
return;
}
LOGGER.error("Verification of Minecraft {} signature failed: {}", path.getFileName(), e.getMessage());
throw e;
}
}
private boolean isValidKnownVersion(Path path, String version, KnownJarType type) throws IOException, SignatureVerificationFailure {
Map<String, String> knownVersions = type.getKnownVersions();
String expectedHash = knownVersions.get(version);
if (expectedHash == null) {
return false;
}
LOGGER.info("Found executed hash ({}) for known version: {}", expectedHash, version);
String hash = Checksum.sha256Hex(Files.readAllBytes(path));
if (hash.equalsIgnoreCase(expectedHash)) {
LOGGER.info("Minecraft {} hash matches known version", path.getFileName());
return true;
}
throw new SignatureVerificationFailure("Hash mismatch for known Minecraft version " + version + ": expected " + expectedHash + ", got " + hash);
}
private enum KnownJarType {
CLIENT(KnownVersions::client),
SERVER(KnownVersions::server),;
private final Function<KnownVersions, Map<String, String>> knownVersions;
KnownJarType(Function<KnownVersions, Map<String, String>> knownVersions) {
this.knownVersions = knownVersions;
}
private Map<String, String> getKnownVersions() {
return knownVersions.apply(KnownVersions.INSTANCE.get());
}
}
}

View File

@@ -0,0 +1,35 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2025 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.configuration.providers.minecraft.verify;
public final class SignatureVerificationFailure extends Exception {
public SignatureVerificationFailure(String message) {
super(message);
}
public SignatureVerificationFailure(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -149,6 +149,10 @@ public class Constants {
public static final String RUNTIME_JAVA_COMPATIBILITY_VERSION = "fabric.loom.runtimeJavaCompatibilityVersion";
public static final String DECOMPILE_CACHE_MAX_FILES = "fabric.loom.decompileCacheMaxFiles";
public static final String DECOMPILE_CACHE_MAX_AGE = "fabric.loom.decompileCacheMaxAge";
/**
* Skip the signature verification of the Minecraft jar after downloading it.
*/
public static final String DISABLE_MINECRAFT_VERIFICATION = "fabric.loom.disableMinecraftVerification";
}
public static final class Manifest {

View File

@@ -46,7 +46,7 @@ public class ZipReprocessorUtil {
private static final String META_INF = "META-INF/";
// See https://docs.oracle.com/en/java/javase/20/docs/specs/jar/jar.html#signed-jar-file
private static boolean isSpecialFile(String zipEntryName) {
public static boolean isSpecialFile(String zipEntryName) {
if (!zipEntryName.startsWith(META_INF)) {
return false;
}

View File

@@ -145,13 +145,13 @@ public final class Download {
}
}
void downloadPath(Path output) throws DownloadException {
DownloadResult downloadPath(Path output) throws DownloadException {
boolean downloadRequired = requiresDownload(output);
if (!downloadRequired) {
// Does not require download, we are done here.
progressListener.onEnd();
return;
return new DownloadResultImpl(false);
}
try {
@@ -162,6 +162,8 @@ public final class Download {
} finally {
progressListener.onEnd();
}
return new DownloadResultImpl(true);
}
private void doDownload(Path output) throws DownloadException {
@@ -483,4 +485,6 @@ public final class Download {
private DownloadException error(Throwable throwable, String message, Object... args) {
return new DownloadException(message.formatted(args), throwable);
}
private record DownloadResultImpl(boolean didDownload) implements DownloadResult { }
}

View File

@@ -33,6 +33,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
@SuppressWarnings("UnusedReturnValue")
public class DownloadBuilder {
@@ -115,15 +116,12 @@ public class DownloadBuilder {
return new Download(this.url, this.expectedHash, this.useEtag, this.forceDownload, this.offline, maxAge, progressListener, httpVersion, downloadAttempt);
}
public void downloadPathAsync(Path path, DownloadExecutor executor) {
executor.runAsync(() -> downloadPath(path));
public CompletableFuture<DownloadResult> downloadPathAsync(Path path, DownloadExecutor executor) {
return executor.runAsync(() -> downloadPath(path));
}
public void downloadPath(Path path) throws DownloadException {
withRetries((download) -> {
download.downloadPath(path);
return null;
});
public DownloadResult downloadPath(Path path) throws DownloadException {
return withRetries((download) -> download.downloadPath(path));
}
public String downloadString() throws DownloadException {

View File

@@ -24,10 +24,11 @@
package net.fabricmc.loom.util.download;
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
@@ -40,20 +41,20 @@ public class DownloadExecutor implements AutoCloseable {
executorService = Executors.newFixedThreadPool(threads);
}
void runAsync(DownloadRunner downloadRunner) {
CompletableFuture<DownloadResult> runAsync(DownloadRunner downloadRunner) {
if (!downloadExceptions.isEmpty()) {
return;
return CompletableFuture.failedFuture(new DownloadException("Download blocked due to previous errors"));
}
executorService.execute(() -> {
return CompletableFuture.supplyAsync(() -> {
try {
downloadRunner.run();
return downloadRunner.run();
} catch (DownloadException e) {
executorService.shutdownNow();
downloadExceptions.add(e);
throw new UncheckedIOException(e);
throw new CompletionException(e);
}
});
}, executorService);
}
@Override
@@ -79,6 +80,6 @@ public class DownloadExecutor implements AutoCloseable {
@FunctionalInterface
public interface DownloadRunner {
void run() throws DownloadException;
DownloadResult run() throws DownloadException;
}
}

View File

@@ -0,0 +1,29 @@
/*
* This file is part of fabric-loom, licensed under the MIT License (MIT).
*
* Copyright (c) 2025 FabricMC
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package net.fabricmc.loom.util.download;
public interface DownloadResult {
boolean didDownload();
}