diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..aa00ffa --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index f4feca1..2777d9f 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,22 @@ This plugin adds naturally spawning Giants with AI to your Minecraft server. -### Requested features -- Texture variation \ - I think this can be done, but would require a texture pack -- Boss bar \ - I don't want to get buried in that, so this would require an API, which, for performance reasons, should not be mandatory -- Hitboxes \ - I don't know if this is still a major issue, but a person reported that the hitbox doesn't cover the whole body \ No newline at end of file +### Signing +Public key goes into `resources/verifies_downloaded_jars.pem` + +A test (and default) keystore is provided: +- keystore: `testkeystore` +- storepass: `123456` +- alias: `testkey` + +When using `mvn`, override with `-Djarsigner.` +``` +mvn clean package -Djarsigner.keystore=/home/user/mykeystore.jks -Djarsigner.alias=mykey +``` + +To create a keystore and export public key: +``` +keytool -keystore testkeystore2.jks -genkeypair -keyalg RSA -alias testkey -validity 999999 +keytool -exportcert -alias testkey -keystore testkeystore2.jks -file cert.cer -rfc +openssl x509 -inform pem -in cert.cer -pubkey -noout > public_key.pem +``` \ No newline at end of file diff --git a/pom.xml b/pom.xml index e99f02a..74a60cd 100644 --- a/pom.xml +++ b/pom.xml @@ -5,8 +5,12 @@ 2.0.8-SNAPSHOT - 11 - 11 + 11 + ${project.basedir}/testkeystore.jks + testkey + 123456 + UTF-8 + UTF-8 @@ -53,7 +57,7 @@ eu.m724 jarupdater - 0.1.3 + 0.1.5 @@ -79,6 +83,7 @@ 3.6.0 false + true @@ -93,6 +98,15 @@ eu.m724:jarupdater + + + * + + META-INF/MANIFEST.MF + META-INF/maven/** + + + @@ -103,6 +117,30 @@ + + org.apache.maven.plugins + maven-jarsigner-plugin + 3.1.0 + + + sign + + sign + + + + verify + + verify + + + + + ${jarsigner.keystore} + ${jarsigner.alias} + ${jarsigner.storepass} + + diff --git a/src/main/java/eu/m724/giants/GiantProcessor.java b/src/main/java/eu/m724/giants/GiantProcessor.java index 7aff656..456c592 100644 --- a/src/main/java/eu/m724/giants/GiantProcessor.java +++ b/src/main/java/eu/m724/giants/GiantProcessor.java @@ -1,14 +1,12 @@ package eu.m724.giants; import org.bukkit.Location; -import org.bukkit.Material; import org.bukkit.NamespacedKey; import org.bukkit.entity.*; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.entity.EntityDeathEvent; import org.bukkit.event.entity.EntitySpawnEvent; -import org.bukkit.event.entity.EntityTargetLivingEntityEvent; import org.bukkit.event.world.ChunkLoadEvent; import org.bukkit.inventory.ItemStack; import org.bukkit.persistence.PersistentDataType; diff --git a/src/main/java/eu/m724/giants/GiantsPlugin.java b/src/main/java/eu/m724/giants/GiantsPlugin.java index bfdf89d..1fd19cc 100644 --- a/src/main/java/eu/m724/giants/GiantsPlugin.java +++ b/src/main/java/eu/m724/giants/GiantsPlugin.java @@ -1,5 +1,6 @@ package eu.m724.giants; +import eu.m724.giants.updater.JarVerifier; import eu.m724.giants.updater.PluginUpdater; import eu.m724.giants.updater.UpdateCommand; import org.bstats.bukkit.Metrics; @@ -10,7 +11,8 @@ import org.bukkit.entity.LivingEntity; import org.bukkit.plugin.java.JavaPlugin; import java.io.File; -import java.lang.reflect.Constructor; +import java.io.IOException; +import java.io.InputStream; public class GiantsPlugin extends JavaPlugin implements CommandExecutor { private final File configFile = new File(getDataFolder(), "config.yml"); @@ -28,7 +30,7 @@ public class GiantsPlugin extends JavaPlugin implements CommandExecutor { UpdateCommand updateCommand = null; if (configuration.updater != null) { - PluginUpdater updater = PluginUpdater.build(this, configuration.updater); + PluginUpdater updater = PluginUpdater.build(this, getFile(), configuration.updater); updater.initNotifier(); updateCommand = new UpdateCommand(updater); } @@ -37,7 +39,22 @@ public class GiantsPlugin extends JavaPlugin implements CommandExecutor { giantProcessor.start(); - // bStats is optional + new Metrics(this, 14131); + + try (InputStream keyInputStream = getResource("verifies_downloaded_jars.pem")) { + JarVerifier.verifyWithRsaKey( + getFile().getPath().replace(".paper-remapped/", ""), // paper remapping removes data from manifest + keyInputStream + ); + } catch (IOException e) { + getLogger().warning(e.getMessage()); + getLogger().warning("Failed checking JAR signature. This is not important right now, but it usually forecasts future problems."); + } catch (JarVerifier.VerificationException e) { + getLogger().warning(e.getMessage()); + getLogger().warning("Plugin JAR is of invalid signature. It's possible that the signature has changed, in which case it's normal. But I can't verify that, you must see the version changelog yourself."); + } + + /* bStats is optional. not anymore try { Class clazz = Class.forName("eu.m724.giants.bukkit.Metrics"); Constructor constructor = clazz.getDeclaredConstructor(JavaPlugin.class, int.class); @@ -45,10 +62,12 @@ public class GiantsPlugin extends JavaPlugin implements CommandExecutor { getLogger().info("Enabled bStats"); } catch (Exception e) { getLogger().info("Not using bStats (" + e.getClass().getName() + ")"); - } + }*/ } + + // TODO api, untested /** diff --git a/src/main/java/eu/m724/giants/updater/JarVerifier.java b/src/main/java/eu/m724/giants/updater/JarVerifier.java new file mode 100644 index 0000000..e4a0133 --- /dev/null +++ b/src/main/java/eu/m724/giants/updater/JarVerifier.java @@ -0,0 +1,134 @@ +package eu.m724.giants.updater; + +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.Base64; +import java.util.Enumeration; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.Manifest; + +public class JarVerifier { + /** + * 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 + */ + private 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 keyInputStream inputStream of the public key file + * @throws VerificationException if verification failed + */ + public static void verifyWithRsaKey(String jarPath, InputStream keyInputStream) throws VerificationException { + try { + // Load the RSA public key + RSAPublicKey publicKey = loadPublicKey(keyInputStream); + + // 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; + for (CodeSigner signer : signers) { + for (Certificate cert : signer.getSignerCertPath().getCertificates()) { + PublicKey certPublicKey = cert.getPublicKey(); + if (certPublicKey instanceof RSAPublicKey) { + RSAPublicKey rsaKey = (RSAPublicKey) certPublicKey; + if (rsaKey.getModulus().equals(publicKey.getModulus()) && + rsaKey.getPublicExponent().equals(publicKey.getPublicExponent())) { + keyMatch = true; + break; + } + } + } + if (keyMatch) break; + } + + if (!keyMatch) { + throw new VerificationException("Entry not signed with matching RSA key: " + entry.getName()); + } + } + } + + } catch (IOException e) { + throw new VerificationException("Verification error: " + e.getMessage(), e); + } + } + + public static class VerificationException extends Exception { + public VerificationException(String message) { + super(message); + } + + public VerificationException(String message, Exception exception) { + super(message, exception); + } + } +} \ No newline at end of file diff --git a/src/main/java/eu/m724/giants/updater/PluginEnvironment.java b/src/main/java/eu/m724/giants/updater/PluginEnvironment.java index ce22d9d..25c6653 100644 --- a/src/main/java/eu/m724/giants/updater/PluginEnvironment.java +++ b/src/main/java/eu/m724/giants/updater/PluginEnvironment.java @@ -6,9 +6,7 @@ import org.bukkit.plugin.Plugin; import java.nio.file.Path; public class PluginEnvironment extends ConstantEnvironment { - public PluginEnvironment(Plugin plugin, String channel) { - super(plugin.getDescription().getVersion(), - channel, - Path.of(plugin.getClass().getProtectionDomain().getCodeSource().getLocation().getPath())); + public PluginEnvironment(Plugin plugin, String channel, Path path) { + super(plugin.getDescription().getVersion(), channel, path); } } \ No newline at end of file diff --git a/src/main/java/eu/m724/giants/updater/PluginUpdater.java b/src/main/java/eu/m724/giants/updater/PluginUpdater.java index 28aba62..9af7f19 100644 --- a/src/main/java/eu/m724/giants/updater/PluginUpdater.java +++ b/src/main/java/eu/m724/giants/updater/PluginUpdater.java @@ -1,24 +1,30 @@ package eu.m724.giants.updater; -import eu.m724.jarupdater.Updater; import eu.m724.jarupdater.download.Downloader; import eu.m724.jarupdater.download.SimpleDownloader; import eu.m724.jarupdater.environment.Environment; import eu.m724.jarupdater.live.GiteaMetadataDAO; import eu.m724.jarupdater.live.MetadataDAO; import eu.m724.jarupdater.live.MetadataFacade; +import eu.m724.jarupdater.updater.Updater; import org.bukkit.plugin.Plugin; +import java.io.File; +import java.nio.file.NoSuchFileException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + public class PluginUpdater extends Updater { private final Plugin plugin; + boolean updatePending = false; private PluginUpdater(Environment environment, MetadataFacade metadataProvider, Downloader downloader, Plugin plugin) { super(environment, metadataProvider, downloader); this.plugin = plugin; } - public static PluginUpdater build(Plugin plugin, String channel) { - Environment environment = new PluginEnvironment(plugin, channel); + public static PluginUpdater build(Plugin plugin, File file, String channel) { + Environment environment = new PluginEnvironment(plugin, channel, file.toPath()); MetadataDAO metadataDAO = new GiteaMetadataDAO("https://git.m724.eu/Minecon724/giants-metadata", "master"); MetadataFacade metadataFacade = new MetadataFacade(environment, metadataDAO); Downloader downloader = new SimpleDownloader("giants"); @@ -30,4 +36,26 @@ public class PluginUpdater extends Updater { UpdateNotifier updateNotifier = new UpdateNotifier(plugin, this, (version) -> {}); updateNotifier.register(); } + + @Override + public CompletableFuture installLatestVersion() throws NoSuchFileException { + return installLatestVersion(true); + } + + public CompletableFuture installLatestVersion(boolean verify) throws NoSuchFileException { + if (this.downloaded == null) { + throw new NoSuchFileException("Download it first"); + } else { + return this.downloaded.thenCompose((file) -> { + if (verify) { + try { + JarVerifier.verifyWithRsaKey(file.getPath(), plugin.getResource("verifies_downloaded_jars.pem")); + } catch (JarVerifier.VerificationException e) { + throw new CompletionException(e); + } + } + return this.downloader.install(file, this.environment.getRunningJarFilePath().toFile()); + }); + } + } } diff --git a/src/main/java/eu/m724/giants/updater/UpdateCommand.java b/src/main/java/eu/m724/giants/updater/UpdateCommand.java index e77d05b..d0d55e0 100644 --- a/src/main/java/eu/m724/giants/updater/UpdateCommand.java +++ b/src/main/java/eu/m724/giants/updater/UpdateCommand.java @@ -1,34 +1,32 @@ package eu.m724.giants.updater; -import eu.m724.jarupdater.Updater; -import eu.m724.jarupdater.object.Version; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; import java.nio.file.NoSuchFileException; -import java.text.SimpleDateFormat; -import java.time.Instant; import java.time.LocalDate; import java.time.format.DateTimeFormatter; -import java.util.concurrent.CompletableFuture; /** * not actually a command but deserves a separate file */ public class UpdateCommand { - private final Updater updater; + private final PluginUpdater updater; - public UpdateCommand(Updater updater) { + public UpdateCommand(PluginUpdater updater) { this.updater = updater; } public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { sender.sendMessage("Please wait..."); sender.sendMessage("Channel: " + updater.getEnvironment().getChannel()); - CompletableFuture latestFuture = updater.getLatestVersion(); + + if (updater.updatePending) { + sender.sendMessage("Server restart required"); + } if (args.length == 1) { // remember this function is proxied - latestFuture.thenAccept(metadata -> { + updater.getLatestVersion().thenAccept(metadata -> { if (metadata != null) { sender.sendMessage("An update is available!"); sender.sendMessage("Giants " + metadata.getLabel() + " released " + formatDate(metadata.getTimestamp())); @@ -70,6 +68,7 @@ public class UpdateCommand { try { updater.installLatestVersion().thenAccept(v -> { sender.sendMessage("Installation completed, restart server to apply."); + updater.updatePending = true; }).exceptionally(e -> { sender.sendMessage("Install failed, see console for details. " + e.getMessage()); e.printStackTrace(); @@ -86,7 +85,7 @@ public class UpdateCommand { return true; } - private String formatDate(long timestamp) { // TODO move this + private String formatDate(long timestamp) { return LocalDate.ofEpochDay(timestamp / 86400).format(DateTimeFormatter.ofPattern("dd.MM.yyyy")); } diff --git a/src/main/java/eu/m724/giants/updater/UpdateNotifier.java b/src/main/java/eu/m724/giants/updater/UpdateNotifier.java index bfd7c3d..eca4bfe 100644 --- a/src/main/java/eu/m724/giants/updater/UpdateNotifier.java +++ b/src/main/java/eu/m724/giants/updater/UpdateNotifier.java @@ -1,7 +1,7 @@ package eu.m724.giants.updater; -import eu.m724.jarupdater.Updater; import eu.m724.jarupdater.object.Version; +import eu.m724.jarupdater.updater.Updater; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; diff --git a/src/main/resources/verifies_downloaded_jars.pem b/src/main/resources/verifies_downloaded_jars.pem new file mode 100644 index 0000000..dd6058a --- /dev/null +++ b/src/main/resources/verifies_downloaded_jars.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxjjjayrwlo3cnv+rX1EX +lJN9vHS9MNfvE7zFOHr2JEAx2fRosb2oRzNK0ssoHJFOgrwLWIqrLVS8bTHRujsF +asck2Z1RY5UGe34vNQ5u5MZvm4G25LggC6+ei2kEptoAfgp9kjmeKVPiSnruLn7N +YQc9U4nmr/vJg+SNmy00EkXFU5z3ZsLf8aCjx9rtogZzyZmVPXEDGY3ZjzZxOpv9 +TAvSQlmrc6qmLlY7XZmJMtbzCTq+qqemZBKp6WpNmEogpPgXamOrET434+oE7OCz ++WCFKsVN8qbrQdFLf1HSjghvDoIjHcGfz6cP4nBonSKIfMcr+NziAVmimfqOiDxa +nwIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/testkeystore.jks b/testkeystore.jks new file mode 100644 index 0000000..6c10b16 Binary files /dev/null and b/testkeystore.jks differ