package ru.gravit.launchserver.binary; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Path; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.Security; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.jar.Attributes; import java.util.jar.Manifest; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import org.bouncycastle.cert.jcajce.JcaCertStore; import org.bouncycastle.cms.CMSProcessableByteArray; import org.bouncycastle.cms.CMSSignedData; import org.bouncycastle.cms.CMSSignedDataGenerator; import org.bouncycastle.cms.CMSTypedData; 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.jcajce.JcaContentSignerBuilder; import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; import org.bouncycastle.util.Store; import ru.gravit.utils.helper.IOHelper; /** * 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 { /** * Helper output stream that also sends the data to the given {@link com.google.common.hash.Hasher}. */ private static class HashingOutputStream extends OutputStream { private final OutputStream out; private 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); } } 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 hashFunctionName = "SHA-256"; 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); } } private static MessageDigest hasher() { try { return MessageDigest.getInstance(hashFunctionName); } catch (NoSuchAlgorithmException e) { return null; } } private final ZipOutputStream zos; private final KeyStore keyStore; private final String keyAlias; private final String password; private final Map manifestAttributes; private String manifestHash; private String manifestMainHash; private final Map fileDigests; private final Map sectionDigests; /** * Constructor. * * @param out the output stream to write JAR data to * @param keyStore the key store to load given key from * @param keyAlias the name of the key in the store, this key is used to sign the JAR * @param keyPassword the password to access the key */ public SignerJar(OutputStream out, KeyStore keyStore, String keyAlias, String keyPassword) { zos = new ZipOutputStream(out); this.keyStore = keyStore; this.keyAlias = keyAlias; password = keyPassword; 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 java.io.IOException * @throws NullPointerException if any of the arguments is {@code null} */ public void addFileContents(String filename, byte[] contents) throws IOException { zos.putNextEntry(new ZipEntry(filename)); zos.write(contents); zos.closeEntry(); String hashCode64 = Base64.getEncoder().encodeToString(hasher().digest(contents)); fileDigests.put(filename, hashCode64); } /** * 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 java.io.IOException * @throws NullPointerException if any of the arguments is {@code null} */ public void addFileContents(String filename, InputStream contents) throws IOException { zos.putNextEntry(new ZipEntry(filename)); byte[] arr = IOHelper.toByteArray(contents); zos.write(arr); zos.closeEntry(); String hashCode64 = Base64.getEncoder().encodeToString(hasher().digest(arr)); fileDigests.put(filename, hashCode64); } /** * 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 java.io.IOException * @throws NullPointerException if any of the arguments is {@code null} */ public void addFileContents(ZipEntry entry, byte[] contents) throws IOException { zos.putNextEntry(entry); zos.write(contents); zos.closeEntry(); String hashCode64 = Base64.getEncoder().encodeToString(hasher().digest(contents)); fileDigests.put(entry.getName(), hashCode64); } /** * 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 java.io.IOException * @throws NullPointerException if any of the arguments is {@code null} */ public void addFileContents(ZipEntry entry, InputStream contents) throws IOException { zos.putNextEntry(entry); byte[] arr = IOHelper.toByteArray(contents); zos.write(arr); zos.closeEntry(); String hashCode64 = Base64.getEncoder().encodeToString(hasher().digest(arr)); fileDigests.put(entry.getName(), hashCode64); } /** * 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, it cannot be longer * than {@value #MANIFEST_ATTR_MAX_LEN} bytes (in utf-8 encoding) * @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 java.io.IOException * @throws RuntimeException if the signing goes wrong */ @Override public void close() throws IOException { finish(); zos.close(); } /** * Creates the beast that can actually sign the data. */ private CMSSignedDataGenerator createSignedDataGenerator() throws Exception { Security.addProvider(new BouncyCastleProvider()); List certChain = new ArrayList<>(Arrays.asList(keyStore.getCertificateChain(keyAlias))); Store certStore = new JcaCertStore(certChain); Certificate cert = keyStore.getCertificate(keyAlias); PrivateKey privateKey = (PrivateKey) keyStore.getKey(keyAlias, password != null ? password.toCharArray() : null); ContentSigner signer = new JcaContentSignerBuilder("SHA256WITHRSA").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; } /** * 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 java.io.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(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); MessageDigest hasher = hasher(); SignerJar.HashingOutputStream o = new SignerJar.HashingOutputStream(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) { } }, hasher); manifest.write(o); return Base64.getEncoder().encodeToString(hasher.digest()); } /** * Returns the CMS signed data. */ private byte[] signSigFile(byte[] sigContents) throws Exception { CMSSignedDataGenerator gen = createSignedDataGenerator(); 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 java.io.IOException */ private void writeManifest() throws IOException { zos.putNextEntry(new ZipEntry(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(hashFunctionName + "-Digest"); 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)); } MessageDigest hasher = hasher(); OutputStream out = new SignerJar.HashingOutputStream(zos, hasher); man.write(out); zos.closeEntry(); manifestHash = Base64.getEncoder().encodeToString(hasher.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(new ZipEntry(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(hashFunctionName + "-Digest-Manifest"), manifestHash); mainAttributes.put(new Attributes.Name(hashFunctionName + "-Digest-Manifest-Main-Attributes"), manifestMainHash); // individual files sections Attributes.Name digestAttr = new Attributes.Name(hashFunctionName + "-Digest"); 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 java.io.IOException * @throws RuntimeException if the signing failed */ private void writeSignature(byte[] sigFile) throws IOException { zos.putNextEntry(new ZipEntry(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(); } }