diff --git a/LaunchServer/src/main/java/pro/gravit/launchserver/binary/JARLauncherBinary.java b/LaunchServer/src/main/java/pro/gravit/launchserver/binary/JARLauncherBinary.java index e28d0f04..b8082682 100644 --- a/LaunchServer/src/main/java/pro/gravit/launchserver/binary/JARLauncherBinary.java +++ b/LaunchServer/src/main/java/pro/gravit/launchserver/binary/JARLauncherBinary.java @@ -47,6 +47,7 @@ public void init() { tasks.add(new AdditionalFixesApplyTask(server)); if (!server.config.launcher.attachLibraryBeforeProGuard) tasks.add(new AttachJarsTask(server)); if (server.config.launcher.compress) tasks.add(new CompressBuildTask(server)); + if(server.config.sign.enabled) tasks.add(new SignJarTask(server.config.sign, server)); } @Override diff --git a/LaunchServer/src/main/java/pro/gravit/launchserver/binary/SignerJar.java b/LaunchServer/src/main/java/pro/gravit/launchserver/binary/SignerJar.java new file mode 100644 index 00000000..b0d525cb --- /dev/null +++ b/LaunchServer/src/main/java/pro/gravit/launchserver/binary/SignerJar.java @@ -0,0 +1,287 @@ +package pro.gravit.launchserver.binary; + +import org.bouncycastle.cms.CMSProcessableByteArray; +import org.bouncycastle.cms.CMSSignedData; +import org.bouncycastle.cms.CMSSignedDataGenerator; +import org.bouncycastle.cms.CMSTypedData; +import pro.gravit.launchserver.helper.SignHelper; +import pro.gravit.utils.helper.IOHelper; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Supplier; +import java.util.jar.Attributes; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + + +/** + * Generator of signed Jars. It stores some data in memory therefore it is not suited for creation of large files. The + * usage: + *
+ * KeyStore keystore = KeyStore.getInstance("JKS");
+ * keyStore.load(keystoreStream, "keystorePassword");
+ * SignerJar jar = new SignerJar(out, keyStore, "keyAlias", "keyPassword");
+ * signedJar.addManifestAttribute("Main-Class", "com.example.MainClass");
+ * signedJar.addManifestAttribute("Application-Name", "Example");
+ * signedJar.addManifestAttribute("Permissions", "all-permissions");
+ * signedJar.addManifestAttribute("Codebase", "*");
+ * signedJar.addFileContents("com/example/MainClass.class", clsData);
+ * signedJar.addFileContents("JNLP-INF/APPLICATION.JNLP", generateJnlpContents());
+ * signedJar.close();
+ * 
+ */ +public class SignerJar implements AutoCloseable { + + private static final String MANIFEST_FN = "META-INF/MANIFEST.MF"; + private static final String SIG_FN = "META-INF/SIGNUMO.SF"; + private static final String SIG_RSA_FN = "META-INF/SIGNUMO.RSA"; + private static final String DIGEST_HASH = SignHelper.hashFunctionName + "-Digest"; + + private final ZipOutputStream zos; + + private final Map manifestAttributes; + private String manifestHash; + private String manifestMainHash; + + private final Map fileDigests; + + private final Map sectionDigests; + private final Supplier gen; + + public SignerJar(ZipOutputStream out, Supplier gen) { + zos = out; + this.gen = gen; + manifestAttributes = new LinkedHashMap<>(); + fileDigests = new LinkedHashMap<>(); + sectionDigests = new LinkedHashMap<>(); + } + + /** + * Adds a file to the JAR. The file is immediately added to the zipped output stream. This method cannot be called once + * the stream is closed. + * + * @param filename name of the file to add (use forward slash as a path separator) + * @param contents contents of the file + * @throws IOException + * @throws NullPointerException if any of the arguments is {@code null} + */ + public void addFileContents(String filename, byte[] contents) throws IOException { + addFileContents(filename, new ByteArrayInputStream(contents)); + } + + /** + * Adds a file to the JAR. The file is immediately added to the zipped output stream. This method cannot be called once + * the stream is closed. + * + * @param filename name of the file to add (use forward slash as a path separator) + * @param contents contents of the file + * @throws IOException + * @throws NullPointerException if any of the arguments is {@code null} + */ + public void addFileContents(String filename, InputStream contents) throws IOException { + addFileContents(IOHelper.newZipEntry(filename), contents); + } + + /** + * Adds a file to the JAR. The file is immediately added to the zipped output stream. This method cannot be called once + * the stream is closed. + * + * @param entry name of the file to add (use forward slash as a path separator) + * @param contents contents of the file + * @throws IOException + * @throws NullPointerException if any of the arguments is {@code null} + */ + public void addFileContents(ZipEntry entry, byte[] contents) throws IOException { + addFileContents(entry, new ByteArrayInputStream(contents)); + } + + /** + * Adds a file to the JAR. The file is immediately added to the zipped output stream. This method cannot be called once + * the stream is closed. + * + * @param entry name of the file to add (use forward slash as a path separator) + * @param contents contents of the file + * @throws IOException + * @throws NullPointerException if any of the arguments is {@code null} + */ + public void addFileContents(ZipEntry entry, InputStream contents) throws IOException { + zos.putNextEntry(entry); + SignHelper.HashingOutputStream out = new SignHelper.HashingNonClosingOutputStream(zos, SignHelper.hasher()); + IOHelper.transfer(contents, out); + zos.closeEntry(); + fileDigests.put(entry.getName(), Base64.getEncoder().encodeToString(out.digest())); + } + + /** + * Adds a header to the manifest of the JAR. + * + * @param name name of the attribute, it is placed into the main section of the manifest file + * @param value value of the attribute + */ + public void addManifestAttribute(String name, String value) { + manifestAttributes.put(name, value); + } + + + /** + * Closes the JAR file by writing the manifest and signature data to it and finishing the ZIP entries. It closes the + * underlying stream. + * + * @throws IOException + * @throws RuntimeException if the signing goes wrong + */ + @Override + public void close() throws IOException { + finish(); + zos.close(); + } + + + /** + * Finishes the JAR file by writing the manifest and signature data to it and finishing the ZIP entries. It leaves the + * underlying stream open. + * + * @throws IOException + * @throws RuntimeException if the signing goes wrong + */ + public void finish() throws IOException { + writeManifest(); + byte[] sig = writeSigFile(); + writeSignature(sig); + zos.finish(); + } + + public ZipOutputStream getZos() { + return zos; + } + + /** + * Helper for {@link #writeManifest()} that creates the digest of one entry. + */ + private String hashEntrySection(String name, Attributes attributes) throws IOException { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + ByteArrayOutputStream o = new ByteArrayOutputStream(); + manifest.write(o); + int emptyLen = o.toByteArray().length; + + manifest.getEntries().put(name, attributes); + + manifest.write(o); + byte[] ob = o.toByteArray(); + ob = Arrays.copyOfRange(ob, emptyLen, ob.length); + return Base64.getEncoder().encodeToString(SignHelper.hasher().digest(ob)); + } + + /** + * Helper for {@link #writeManifest()} that creates the digest of the main section. + */ + private String hashMainSection(Attributes attributes) throws IOException { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().putAll(attributes); + SignHelper.HashingOutputStream o = new SignHelper.HashingNonClosingOutputStream(SignHelper.NULL, SignHelper.hasher()); + manifest.write(o); + return Base64.getEncoder().encodeToString(o.digest()); + } + + /** + * Returns the CMS signed data. + */ + private byte[] signSigFile(byte[] sigContents) throws Exception { + CMSSignedDataGenerator gen = this.gen.get(); + CMSTypedData cmsData = new CMSProcessableByteArray(sigContents); + CMSSignedData signedData = gen.generate(cmsData, true); + return signedData.getEncoded(); + } + + /** + * Writes the manifest to the JAR. It also calculates the digests that are required to be placed in the the signature + * file. + * + * @throws IOException + */ + private void writeManifest() throws IOException { + zos.putNextEntry(IOHelper.newZipEntry(MANIFEST_FN)); + Manifest man = new Manifest(); + + // main section + Attributes mainAttributes = man.getMainAttributes(); + mainAttributes.put(Attributes.Name.MANIFEST_VERSION, "1.0"); + + for (Map.Entry entry : manifestAttributes.entrySet()) + mainAttributes.put(new Attributes.Name(entry.getKey()), entry.getValue()); + + // individual files sections + Attributes.Name digestAttr = new Attributes.Name(DIGEST_HASH); + for (Map.Entry entry : fileDigests.entrySet()) { + Attributes attributes = new Attributes(); + man.getEntries().put(entry.getKey(), attributes); + attributes.put(digestAttr, entry.getValue()); + sectionDigests.put(entry.getKey(), hashEntrySection(entry.getKey(), attributes)); + } + + SignHelper.HashingOutputStream out = new SignHelper.HashingNonClosingOutputStream(zos, SignHelper.hasher()); + man.write(out); + zos.closeEntry(); + + manifestHash = Base64.getEncoder().encodeToString(out.digest()); + manifestMainHash = hashMainSection(man.getMainAttributes()); + } + + /** + * Writes the .SIG file to the JAR. + * + * @return the contents of the file as bytes + */ + private byte[] writeSigFile() throws IOException { + zos.putNextEntry(IOHelper.newZipEntry(SIG_FN)); + Manifest man = new Manifest(); + // main section + Attributes mainAttributes = man.getMainAttributes(); + mainAttributes.put(Attributes.Name.SIGNATURE_VERSION, "1.0"); + mainAttributes.put(new Attributes.Name(DIGEST_HASH + "-Manifest"), manifestHash); + mainAttributes.put(new Attributes.Name(DIGEST_HASH + "-Manifest-Main-Attributes"), manifestMainHash); + + // individual files sections + Attributes.Name digestAttr = new Attributes.Name(DIGEST_HASH); + for (Map.Entry entry : sectionDigests.entrySet()) { + Attributes attributes = new Attributes(); + man.getEntries().put(entry.getKey(), attributes); + attributes.put(digestAttr, entry.getValue()); + } + + man.write(zos); + zos.closeEntry(); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + man.write(baos); + return baos.toByteArray(); + } + + /** + * Signs the .SIG file and writes the signature (.RSA file) to the JAR. + * + * @throws IOException + * @throws RuntimeException if the signing failed + */ + private void writeSignature(byte[] sigFile) throws IOException { + zos.putNextEntry(IOHelper.newZipEntry(SIG_RSA_FN)); + try { + byte[] signature = signSigFile(sigFile); + zos.write(signature); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Signing failed.", e); + } + zos.closeEntry(); + } +} \ No newline at end of file diff --git a/LaunchServer/src/main/java/pro/gravit/launchserver/binary/tasks/SignJarTask.java b/LaunchServer/src/main/java/pro/gravit/launchserver/binary/tasks/SignJarTask.java new file mode 100644 index 00000000..8746b342 --- /dev/null +++ b/LaunchServer/src/main/java/pro/gravit/launchserver/binary/tasks/SignJarTask.java @@ -0,0 +1,78 @@ +package pro.gravit.launchserver.binary.tasks; + +import org.bouncycastle.cms.CMSException; +import org.bouncycastle.cms.CMSSignedDataGenerator; +import org.bouncycastle.operator.OperatorCreationException; +import pro.gravit.launchserver.LaunchServer; +import pro.gravit.launchserver.binary.SignerJar; +import pro.gravit.launchserver.config.LaunchServerConfig; +import pro.gravit.launchserver.helper.SignHelper; +import pro.gravit.utils.helper.IOHelper; +import pro.gravit.utils.helper.LogHelper; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateEncodingException; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +public class SignJarTask implements LauncherBuildTask { + + private final LaunchServerConfig.JarSignerConf config; + private final LaunchServer srv; + + public SignJarTask(LaunchServerConfig.JarSignerConf config, LaunchServer srv) { + this.config = config; + this.srv = srv; + } + + @Override + public String getName() { + return "SignJar"; + } + + @Override + public Path process(Path inputFile) throws IOException { + Path toRet = srv.launcherBinary.nextPath("signed"); + KeyStore c = SignHelper.getStore(new File(config.keyStore).toPath(), config.keyStorePass, config.keyStoreType); + try (SignerJar output = new SignerJar(new ZipOutputStream(IOHelper.newOutput(toRet)), () -> this.gen(c)); + ZipInputStream input = new ZipInputStream(IOHelper.newInput(inputFile))) { + //input.getManifest().getMainAttributes().forEach((a, b) -> output.addManifestAttribute(a.toString(), b.toString())); // may not work such as after Radon. + ZipEntry e = input.getNextEntry(); + while (e != null) { + if ("META-INF/MANIFEST.MF".equals(e.getName()) || "/META-INF/MANIFEST.MF".equals(e.getName())) { + Manifest m = new Manifest(input); + m.getMainAttributes().forEach((a, b) -> output.addManifestAttribute(a.toString(), b.toString())); + e = input.getNextEntry(); + continue; + } + output.addFileContents(IOHelper.newZipEntry(e), input); + e = input.getNextEntry(); + } + } + return toRet; + } + + @Override + public boolean allowDelete() { + return true; + } + + public CMSSignedDataGenerator gen(KeyStore c) { + try { + return SignHelper.createSignedDataGenerator(c, + config.keyAlias, config.signAlgo, config.keyStorePass); + } catch (CertificateEncodingException | UnrecoverableKeyException | KeyStoreException + | OperatorCreationException | NoSuchAlgorithmException | CMSException e) { + LogHelper.error(e); + return null; + } + } +} diff --git a/LaunchServer/src/main/java/pro/gravit/launchserver/config/LaunchServerConfig.java b/LaunchServer/src/main/java/pro/gravit/launchserver/config/LaunchServerConfig.java index fcc9cec0..27b010dd 100644 --- a/LaunchServer/src/main/java/pro/gravit/launchserver/config/LaunchServerConfig.java +++ b/LaunchServer/src/main/java/pro/gravit/launchserver/config/LaunchServerConfig.java @@ -88,6 +88,7 @@ public AuthProviderPair getAuthProviderPair() { public String whitelistRejectString; public LauncherConf launcher; public CertificateConf certificate; + public JarSignerConf sign; public String startScript; @@ -225,6 +226,16 @@ public static class CertificateConf { public boolean enabled; } + public static class JarSignerConf { + public boolean enabled = true; + public String keyStore = "pathToKey"; + public String keyStoreType = "JKS"; + public String keyStorePass = "mypass"; + public String keyAlias = "myname"; + public String keyPass = "mypass"; + public String signAlgo = "SHA256WITHRSA"; + } + public static class NettyUpdatesBind { public String url; public boolean zip; @@ -324,6 +335,7 @@ public static LaunchServerConfig getDefault(LaunchServer.LaunchServerEnv env) { newConfig.certificate = new LaunchServerConfig.CertificateConf(); newConfig.certificate.enabled = false; + newConfig.sign = new JarSignerConf(); newConfig.components = new HashMap<>(); AuthLimiterComponent authLimiterComponent = new AuthLimiterComponent(); diff --git a/LaunchServer/src/main/java/pro/gravit/launchserver/helper/SignHelper.java b/LaunchServer/src/main/java/pro/gravit/launchserver/helper/SignHelper.java new file mode 100644 index 00000000..86e3a165 --- /dev/null +++ b/LaunchServer/src/main/java/pro/gravit/launchserver/helper/SignHelper.java @@ -0,0 +1,158 @@ +package pro.gravit.launchserver.helper; + +import org.bouncycastle.cert.jcajce.JcaCertStore; +import org.bouncycastle.cms.CMSException; +import org.bouncycastle.cms.CMSSignedDataGenerator; +import org.bouncycastle.cms.SignerInfoGenerator; +import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.DigestCalculatorProvider; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; +import org.bouncycastle.util.Store; +import pro.gravit.utils.helper.IOHelper; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Path; +import java.security.*; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class SignHelper { + + /** + * Helper output stream that also sends the data to the given. + */ + public static class HashingOutputStream extends OutputStream { + public final OutputStream out; + public final MessageDigest hasher; + + public HashingOutputStream(OutputStream out, MessageDigest hasher) { + this.out = out; + this.hasher = hasher; + } + + @Override + public void close() throws IOException { + out.close(); + } + + @Override + public void flush() throws IOException { + out.flush(); + } + + @Override + public void write(byte[] b) throws IOException { + out.write(b); + hasher.update(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + out.write(b, off, len); + hasher.update(b, off, len); + } + + @Override + public void write(int b) throws IOException { + out.write(b); + hasher.update((byte) b); + } + + public byte[] digest() { + return hasher.digest(); + } + } + /** + * Helper output stream that also sends the data to the given. + */ + public static class HashingNonClosingOutputStream extends HashingOutputStream { + public HashingNonClosingOutputStream(OutputStream out, MessageDigest hasher) { + super(out, hasher); + } + + @Override + public void close() throws IOException { + // Do nothing + } + } + + public static final OutputStream NULL = new OutputStream() { + @Override + public String toString() { + return "NullOutputStream"; + } + + /** Discards the specified byte array. */ + @Override + public void write(byte[] b) { + } + + /** Discards the specified byte array. */ + @Override + public void write(byte[] b, int off, int len) { + } + + /** Discards the specified byte. */ + @Override + public void write(int b) { + } + + /** Never closes */ + @Override + public void close() { + } + }; + private SignHelper() { + } + + /** + * Creates the KeyStore with given algo. + */ + public static KeyStore getStore(Path file, String storepass, String algo) throws IOException { + try { + KeyStore st = KeyStore.getInstance(algo); + st.load(IOHelper.newInput(file), storepass != null ? storepass.toCharArray() : null); + return st; + } catch (NoSuchAlgorithmException | CertificateException | KeyStoreException e) { + throw new IOException(e); + } + } + + /** + * Creates the beast that can actually sign the data (for JKS, for other make it). + */ + public static CMSSignedDataGenerator createSignedDataGenerator(KeyStore keyStore, String keyAlias, String signAlgo, String keyPassword) throws KeyStoreException, OperatorCreationException, CertificateEncodingException, UnrecoverableKeyException, NoSuchAlgorithmException, CMSException { + List certChain = new ArrayList<>(Arrays.asList(keyStore.getCertificateChain(keyAlias))); + @SuppressWarnings("rawtypes") + Store certStore = new JcaCertStore(certChain); + Certificate cert = keyStore.getCertificate(keyAlias); + PrivateKey privateKey = (PrivateKey) keyStore.getKey(keyAlias, keyPassword != null ? keyPassword.toCharArray() : null); + ContentSigner signer = new JcaContentSignerBuilder(signAlgo).setProvider("BC").build(privateKey); + CMSSignedDataGenerator generator = new CMSSignedDataGenerator(); + DigestCalculatorProvider dcp = new JcaDigestCalculatorProviderBuilder().setProvider("BC").build(); + SignerInfoGenerator sig = new JcaSignerInfoGeneratorBuilder(dcp).build(signer, (X509Certificate) cert); + generator.addSignerInfoGenerator(sig); + generator.addCertificates(certStore); + return generator; + } + + public static final String hashFunctionName = "SHA-256"; + + public static MessageDigest hasher() { + try { + return MessageDigest.getInstance(hashFunctionName); + } catch (NoSuchAlgorithmException e) { + return null; + } + } +} diff --git a/modules b/modules index bcaa597a..6f008f59 160000 --- a/modules +++ b/modules @@ -1 +1 @@ -Subproject commit bcaa597a85e3ff749144fabc182e47b26f4f14ef +Subproject commit 6f008f595975887b6a1366cb444b82b339e3afad