mirror of
https://github.com/architectury/architectury-loom.git
synced 2026-04-02 05:27:43 -05:00
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:
@@ -93,7 +93,6 @@ public final class MergedMinecraftProvider extends MinecraftProvider {
|
||||
File minecraftServerJar = getMinecraftServerJar();
|
||||
|
||||
if (getServerBundleMetadata() != null) {
|
||||
extractBundledServerJar();
|
||||
minecraftServerJar = getMinecraftExtractedServerJar();
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user