From 247bdfddf7d4fb2696837ecdc60acd93dc5fd5bb Mon Sep 17 00:00:00 2001 From: Minecon724 Date: Thu, 31 Oct 2024 16:53:03 +0100 Subject: [PATCH] Add signature verification Closes #1 --- .../eu/m724/jarupdater/updater/Updater.java | 25 +++- .../jarupdater/verify/SignatureVerifier.java | 41 ++++++ .../verify/SignatureVerifierImpl.java | 128 ++++++++++++++++++ .../verify/VerificationException.java | 11 ++ .../eu/m724/jarupdater/verify/Verifier.java | 5 + 5 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 src/main/java/eu/m724/jarupdater/verify/SignatureVerifier.java create mode 100644 src/main/java/eu/m724/jarupdater/verify/SignatureVerifierImpl.java create mode 100644 src/main/java/eu/m724/jarupdater/verify/VerificationException.java create mode 100644 src/main/java/eu/m724/jarupdater/verify/Verifier.java diff --git a/src/main/java/eu/m724/jarupdater/updater/Updater.java b/src/main/java/eu/m724/jarupdater/updater/Updater.java index 497e97a..c7d7372 100644 --- a/src/main/java/eu/m724/jarupdater/updater/Updater.java +++ b/src/main/java/eu/m724/jarupdater/updater/Updater.java @@ -1,5 +1,12 @@ package eu.m724.jarupdater.updater; +import eu.m724.jarupdater.download.Downloader; +import eu.m724.jarupdater.environment.Environment; +import eu.m724.jarupdater.live.MetadataFacade; +import eu.m724.jarupdater.object.Version; +import eu.m724.jarupdater.verify.VerificationException; +import eu.m724.jarupdater.verify.Verifier; + import java.io.File; import java.io.IOException; import java.nio.file.NoSuchFileException; @@ -7,22 +14,19 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; -import eu.m724.jarupdater.download.Downloader; -import eu.m724.jarupdater.environment.Environment; -import eu.m724.jarupdater.live.MetadataFacade; -import eu.m724.jarupdater.object.Version; - public class Updater { protected Environment environment; protected MetadataFacade metadataProvider; protected Downloader downloader; + protected Verifier verifier; protected CompletableFuture downloaded; - public Updater(Environment environment, MetadataFacade metadataProvider, Downloader downloader) { + public Updater(Environment environment, MetadataFacade metadataProvider, Downloader downloader, Verifier verifier) { this.environment = environment; this.metadataProvider = metadataProvider; this.downloader = downloader; + this.verifier = verifier; } public Environment getEnvironment() { @@ -91,7 +95,14 @@ public class Updater { if (downloaded == null) throw new NoSuchFileException("Download it first"); - return downloaded.thenCompose(file -> downloader.install(file, environment.getRunningJarFilePath().toFile())); + return downloaded.thenCompose(file -> { + try { + verifier.verify(file.getAbsolutePath()); + } catch (VerificationException e) { + throw new CompletionException(e); + } + return downloader.install(file, environment.getRunningJarFilePath().toFile()); + }); } } diff --git a/src/main/java/eu/m724/jarupdater/verify/SignatureVerifier.java b/src/main/java/eu/m724/jarupdater/verify/SignatureVerifier.java new file mode 100644 index 0000000..3c4ae48 --- /dev/null +++ b/src/main/java/eu/m724/jarupdater/verify/SignatureVerifier.java @@ -0,0 +1,41 @@ +package eu.m724.jarupdater.verify; + +import java.io.IOException; +import java.io.InputStream; +import java.security.interfaces.RSAPublicKey; +import java.util.HashSet; +import java.util.Set; +/** + * Utility for verifying downloaded JARs with RSA signatures + */ +public class SignatureVerifier implements Verifier { + private final Set publicKeys = new HashSet<>(); + + /** + * Load an RSA public key + * @param inputStream the input stream of the public key file + */ + public void loadPublicKey(InputStream inputStream) throws IOException { + RSAPublicKey publicKey = SignatureVerifierImpl.loadPublicKey(inputStream); + publicKeys.add(publicKey); + } + + /** + * How much public keys loaded + * @return amount of public keys loaded + */ + public int publicKeysCount() { + return publicKeys.size(); + } + + /** + * Verify a JAR with loaded public keys + * + * @param jarPath the path of the JAR file + * @throws VerificationException if something went wrong or not signed with known keys + */ + public void verify(String jarPath) throws VerificationException { + SignatureVerifierImpl.verifyWithRsaKey(jarPath, publicKeys.toArray(RSAPublicKey[]::new)); + } + +} diff --git a/src/main/java/eu/m724/jarupdater/verify/SignatureVerifierImpl.java b/src/main/java/eu/m724/jarupdater/verify/SignatureVerifierImpl.java new file mode 100644 index 0000000..d879713 --- /dev/null +++ b/src/main/java/eu/m724/jarupdater/verify/SignatureVerifierImpl.java @@ -0,0 +1,128 @@ +package eu.m724.jarupdater.verify; + +import java.io.IOException; +import java.io.InputStream; +import java.security.CodeSigner; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.cert.Certificate; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.X509EncodedKeySpec; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Enumeration; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.Manifest; + +public class SignatureVerifierImpl { + /** + * Loads an RSA public key from a PEM file + * + * @param keyInputStream inputStream of the public key file + * @return {@link RSAPublicKey} instance of the public key + * @throws IOException if reading the key input stream failed + */ + static RSAPublicKey loadPublicKey(InputStream keyInputStream) throws IOException { + // Read the key file + String keyContent = new String(keyInputStream.readAllBytes()); + + // Remove PEM headers and newlines + keyContent = keyContent.replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s+", ""); + + // Decode the key + byte[] keyBytes = Base64.getDecoder().decode(keyContent); + + // Create public key specification + X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes); + + // Generate public key + try { + KeyFactory kf = KeyFactory.getInstance("RSA"); + return (RSAPublicKey) kf.generatePublic(spec); + } catch (GeneralSecurityException e) { + // because this shouldn't happen + throw new RuntimeException(e); + } + } + + /** + * Verifies if a JAR file's signature matches an RSA public key + * + * @param jarPath the path of the JAR file + * @param publicKeys the {@link RSAPublicKey}s + * @throws VerificationException if verification failed + */ + static void verifyWithRsaKey(String jarPath, RSAPublicKey... publicKeys) throws VerificationException { + try { + // Open the JAR file + try (JarFile jarFile = new JarFile(jarPath, true)) { + byte[] buffer = new byte[8192]; + Enumeration entries = jarFile.entries(); + + // Get manifest to check signature files + Manifest manifest = jarFile.getManifest(); + if (manifest == null) { + throw new VerificationException("JAR has no manifest"); + } + + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + + if (entry.isDirectory() || entry.getName().startsWith("META-INF/")) { + continue; + } + + int bytesRead = 0; + // Read entry to trigger signature verification + try (InputStream is = jarFile.getInputStream(entry)) { + while ((bytesRead += is.read(buffer)) != -1) { + if (bytesRead > 1024 * 1024 * 100) { // unusual for a file to have >100 MiB + throw new IOException("File too large: " + entry.getName()); + } + } + } + + // Get signers for this entry + CodeSigner[] signers = entry.getCodeSigners(); + if (signers == null || signers.length == 0) { + throw new VerificationException("Unsigned entry: " + entry.getName()); + } + + // Check if any signer's public key matches our RSA key + boolean keyMatch = false; + List signerPublicKeys = new ArrayList<>(); + + for (CodeSigner signer : signers) { + for (Certificate cert : signer.getSignerCertPath().getCertificates()) { + PublicKey certPublicKey = cert.getPublicKey(); + if (certPublicKey instanceof RSAPublicKey rsaKey) { + signerPublicKeys.add(Base64.getEncoder().encodeToString(rsaKey.getEncoded())); + + for (RSAPublicKey publicKey : publicKeys) { + if (rsaKey.getModulus().equals(publicKey.getModulus()) && + rsaKey.getPublicExponent().equals(publicKey.getPublicExponent())) { + keyMatch = true; + break; + } + } + } + } + if (keyMatch) break; + } + + if (!keyMatch) { + throw new VerificationException("Entry " + entry.getName() + " signed with unknown key"); + } + } + } + + } catch (IOException e) { + throw new VerificationException("Verification error: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/eu/m724/jarupdater/verify/VerificationException.java b/src/main/java/eu/m724/jarupdater/verify/VerificationException.java new file mode 100644 index 0000000..c3d1922 --- /dev/null +++ b/src/main/java/eu/m724/jarupdater/verify/VerificationException.java @@ -0,0 +1,11 @@ +package eu.m724.jarupdater.verify; + +public class VerificationException extends Exception { + public VerificationException(String message) { + super(message); + } + + public VerificationException(String message, Exception exception) { + super(message, exception); + } +} diff --git a/src/main/java/eu/m724/jarupdater/verify/Verifier.java b/src/main/java/eu/m724/jarupdater/verify/Verifier.java new file mode 100644 index 0000000..e20137f --- /dev/null +++ b/src/main/java/eu/m724/jarupdater/verify/Verifier.java @@ -0,0 +1,5 @@ +package eu.m724.jarupdater.verify; + +public interface Verifier { + void verify(String jarPath) throws VerificationException; +}