├── .gitignore ├── src └── main │ └── java │ └── com │ └── inductiveautomation │ └── ignitionsdk │ ├── ZipMapFile.java │ ├── ZipMap.java │ └── ModuleSigner.java ├── README.md └── pom.xml /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | 3 | # Package Files # 4 | *.jar 5 | *.war 6 | *.ear 7 | 8 | # IntelliJ # 9 | *.iml 10 | .idea/* 11 | 12 | # Maven # 13 | **/target/* 14 | target/* 15 | 16 | # Mercurial # 17 | .hg 18 | .hgignore 19 | -------------------------------------------------------------------------------- /src/main/java/com/inductiveautomation/ignitionsdk/ZipMapFile.java: -------------------------------------------------------------------------------- 1 | package com.inductiveautomation.ignitionsdk; 2 | 3 | import java.io.File; 4 | import java.io.FileInputStream; 5 | import java.io.IOException; 6 | 7 | 8 | public class ZipMapFile { 9 | 10 | private byte[] bytes; 11 | private boolean directory; 12 | 13 | public ZipMapFile(File file) { 14 | this(getBytes(file), file.isDirectory()); 15 | } 16 | 17 | public ZipMapFile(byte[] bytes, boolean directory) { 18 | this.bytes = bytes; 19 | this.directory = directory; 20 | } 21 | 22 | public byte[] getBytes() { 23 | return bytes; 24 | } 25 | 26 | public boolean isDirectory() { 27 | return directory; 28 | } 29 | 30 | private static byte[] getBytes(File file) { 31 | FileInputStream fis = null; 32 | 33 | try { 34 | fis = new FileInputStream(file); 35 | 36 | long length = file.length(); 37 | 38 | if (length > Integer.MAX_VALUE) { 39 | // ? 40 | return new byte[]{}; 41 | } 42 | 43 | byte[] bytes = new byte[(int) length]; 44 | 45 | fis.read(bytes); 46 | 47 | return bytes; 48 | } catch (Exception e) { 49 | return new byte[]{}; 50 | } finally { 51 | if (fis != null) { 52 | try { 53 | fis.close(); 54 | } catch (IOException ignored) { 55 | } 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Getting started with module-signer 2 | 3 | ## Prerequisites 4 | 5 | * Java 11 installed and on your path. 6 | * A Java Keystore (in jks or pfx format) containing either: 7 | * A self-generated and self-signed code signing certificate. 8 | * A code signing certificate, obtained from and signed by a CA, and the certificate chain that goes with it. 9 | 10 | [Keystore Explorer](http://keystore-explorer.sourceforge.net/downloads.php) is an easy to use tool for creating and managing keystores and certificates. 11 | 12 | ## Invocation 13 | 14 | Invocation from the command-line: 15 | ``` 16 | java -jar module-signer.jar \ 17 | -keystore=/keystore.jks \ 18 | -keystore-pwd= \ 19 | -alias=server \ 20 | -alias-pwd= \ 21 | -chain=/cert.p7b \ 22 | -module-in=/my-unsigned-module.modl \ 23 | -module-out=/my-signed-module.modl 24 | ``` 25 | 26 | ## Parameters Explained 27 | 28 | ### keystore 29 | The path to the keystore containing your code signing certificate. Can be either JKS or PFX format. 30 | 31 | ### keystore-pwd 32 | The password to access the keystore. 33 | 34 | ### alias 35 | The alias under which your code signing certificate is stored. 36 | 37 | ### alias-pwd 38 | The password to access the alias. 39 | 40 | ### chain 41 | The path to the certificate chain (in p7b format). This file will is generally returned along with your signed certificate after submitting a CSR to a CA. 42 | 43 | ### module-in 44 | The path to the unsigned module. 45 | 46 | ### module-out 47 | The path the signed module will be written to. 48 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | com.inductiveautomation.ignitionsdk 8 | module-signer 9 | 1.0.0-SNAPSHOT 10 | 11 | 12 | 13 | commons-cli 14 | commons-cli 15 | 1.3.1 16 | 17 | 18 | 19 | commons-io 20 | commons-io 21 | 2.1 22 | 23 | 24 | 25 | 26 | 27 | 28 | org.apache.maven.plugins 29 | maven-compiler-plugin 30 | 3.8.0 31 | 32 | 11 33 | 34 | 35 | 36 | org.apache.maven.plugins 37 | maven-jar-plugin 38 | 2.4 39 | 40 | 41 | 42 | com.inductiveautomation.ignitionsdk.ModuleSigner$Main 43 | 44 | 45 | 46 | 47 | 48 | maven-assembly-plugin 49 | 50 | 51 | 52 | com.inductiveautomation.ignitionsdk.ModuleSigner$Main 53 | 54 | 55 | 56 | jar-with-dependencies 57 | 58 | 59 | 60 | 61 | package-assembly 62 | package 63 | 64 | single 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /src/main/java/com/inductiveautomation/ignitionsdk/ZipMap.java: -------------------------------------------------------------------------------- 1 | package com.inductiveautomation.ignitionsdk; 2 | 3 | import java.io.ByteArrayInputStream; 4 | import java.io.ByteArrayOutputStream; 5 | import java.io.File; 6 | import java.io.FileInputStream; 7 | import java.io.FileOutputStream; 8 | import java.io.IOException; 9 | import java.io.InputStream; 10 | import java.io.OutputStream; 11 | import java.util.Collection; 12 | import java.util.HashMap; 13 | import java.util.Map; 14 | import java.util.Set; 15 | import java.util.zip.ZipEntry; 16 | import java.util.zip.ZipInputStream; 17 | import java.util.zip.ZipOutputStream; 18 | 19 | 20 | public class ZipMap implements Map { 21 | 22 | private Map fileMap; 23 | 24 | /** 25 | * Creates an empty ZipMap. Changes need to be explicitly written using {@link #writeToFile(File)} or {@link 26 | * #writeToFile(OutputStream)}. 27 | */ 28 | public ZipMap() { 29 | fileMap = new HashMap<>(); 30 | } 31 | 32 | /** 33 | * Create a ZipMap with the contents of the given zip file. Changes need to be explicitly written using {@link 34 | * #writeToFile(File)} or {@link #writeToFile(OutputStream)}. 35 | * 36 | * @param zipFile 37 | * @throws IOException 38 | */ 39 | public ZipMap(File zipFile) throws IOException { 40 | this(new FileInputStream(zipFile)); 41 | } 42 | 43 | /** 44 | * Create a ZipMap with the contents of the given zip file. Changes need to be explicitly written using {@link 45 | * #writeToFile(File)} or {@link #writeToFile(OutputStream)}. 46 | * 47 | * @param zipFile 48 | * @throws IOException 49 | */ 50 | public ZipMap(InputStream zipFile) throws IOException { 51 | fileMap = new HashMap<>(); 52 | 53 | ZipInputStream zipIn = null; 54 | 55 | try { 56 | zipIn = new ZipInputStream(zipFile); 57 | ZipEntry entry; 58 | 59 | byte[] bytes = new byte[4096]; 60 | 61 | while ((entry = zipIn.getNextEntry()) != null) { 62 | 63 | String entryName = entry.getName(); 64 | 65 | ByteArrayOutputStream bOut = new ByteArrayOutputStream(); 66 | 67 | int bytesRead; 68 | while ((bytesRead = zipIn.read(bytes)) >= 0) { 69 | bOut.write(bytes, 0, bytesRead); 70 | } 71 | bOut.close(); 72 | 73 | final byte[] fileBytes = bOut.toByteArray(); 74 | final boolean directory = entry.isDirectory(); 75 | 76 | fileMap.put(entryName, new ZipMapFile(fileBytes, directory)); 77 | } 78 | } finally { 79 | if (zipIn != null) { 80 | try { 81 | zipIn.close(); 82 | } catch (IOException ignored) { 83 | } 84 | } 85 | } 86 | } 87 | 88 | /** 89 | * Write the contents of this ZipMap to a zip file using the supplied File. 90 | * 91 | * @param outputFile 92 | * @throws IOException 93 | */ 94 | public void writeToFile(File outputFile) throws IOException { 95 | writeToFile(new FileOutputStream(outputFile)); 96 | } 97 | 98 | /** 99 | * Write the contents of this ZipMap to a zip file using the supplied OutputStream. 100 | * 101 | * @param outputStream 102 | * @throws IOException 103 | */ 104 | public void writeToFile(OutputStream outputStream) throws IOException { 105 | ZipOutputStream zos = null; 106 | 107 | try { 108 | zos = new ZipOutputStream(outputStream); 109 | 110 | byte[] buffer = new byte[4096]; 111 | 112 | for (Entry file : fileMap.entrySet()) { 113 | String name = file.getKey(); 114 | ZipMapFile zipMapFile = file.getValue(); 115 | ZipEntry zipEntry = new ZipEntry(name); 116 | 117 | zos.putNextEntry(zipEntry); 118 | 119 | ByteArrayInputStream bytesIn = new ByteArrayInputStream(zipMapFile.getBytes()); 120 | 121 | int length; 122 | while ((length = bytesIn.read(buffer)) > 0) { 123 | zos.write(buffer, 0, length); 124 | } 125 | 126 | zos.closeEntry(); 127 | bytesIn.close(); 128 | } 129 | } finally { 130 | if (zos != null) { 131 | zos.close(); 132 | } 133 | } 134 | } 135 | 136 | public void clear() { 137 | fileMap.clear(); 138 | } 139 | 140 | public boolean containsKey(Object key) { 141 | return fileMap.containsKey(key); 142 | } 143 | 144 | public boolean containsValue(Object value) { 145 | return fileMap.containsValue(value); 146 | } 147 | 148 | public Set> entrySet() { 149 | return fileMap.entrySet(); 150 | } 151 | 152 | public ZipMapFile get(Object key) { 153 | return fileMap.get(key); 154 | } 155 | 156 | public boolean isEmpty() { 157 | return fileMap.isEmpty(); 158 | } 159 | 160 | public Set keySet() { 161 | return fileMap.keySet(); 162 | } 163 | 164 | /** 165 | * Puts an entry into the ZipMap. Directory names should end with a "/". 166 | */ 167 | public ZipMapFile put(String key, ZipMapFile value) { 168 | return fileMap.put(key, value); 169 | } 170 | 171 | /** 172 | * Convenience method for putting a file into the ZipMap. 173 | * 174 | * @param key Name of the file. Example: "hello.txt" or "dir/world.txt". 175 | * @param value The file itself. A ZipMapFile will be created from this File. 176 | * @return The ZipMapFile added to the ZipMap. 177 | */ 178 | public ZipMapFile put(String key, File value) { 179 | return fileMap.put(key, new ZipMapFile(value)); 180 | } 181 | 182 | /** 183 | * Convenience method for putting a file into the ZipMap. 184 | * 185 | * @param key Name of the file. Example: "hello.txt" or "dir/world.txt". 186 | * @param value The byte[] itself. A ZipMapFile will be created from this byte[] with directory=false. 187 | * @return The ZipMapFile added to the ZipMap. 188 | */ 189 | public ZipMapFile put(String key, byte[] value) { 190 | return fileMap.put(key, new ZipMapFile(value, false)); 191 | } 192 | 193 | public void putAll(Map t) { 194 | fileMap.putAll(t); 195 | } 196 | 197 | public ZipMapFile remove(Object key) { 198 | return fileMap.remove(key); 199 | } 200 | 201 | public int size() { 202 | return fileMap.size(); 203 | } 204 | 205 | public Collection values() { 206 | return fileMap.values(); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/main/java/com/inductiveautomation/ignitionsdk/ModuleSigner.java: -------------------------------------------------------------------------------- 1 | package com.inductiveautomation.ignitionsdk; 2 | 3 | import java.io.ByteArrayOutputStream; 4 | import java.io.File; 5 | import java.io.FileInputStream; 6 | import java.io.IOException; 7 | import java.io.InputStream; 8 | import java.io.PrintStream; 9 | import java.io.PrintWriter; 10 | import java.io.StringWriter; 11 | import java.security.GeneralSecurityException; 12 | import java.security.Key; 13 | import java.security.KeyStore; 14 | import java.security.PrivateKey; 15 | import java.security.Provider; 16 | import java.security.Security; 17 | import java.security.Signature; 18 | import java.util.Arrays; 19 | import java.util.Base64; 20 | import java.util.Properties; 21 | 22 | import org.apache.commons.cli.CommandLine; 23 | import org.apache.commons.cli.CommandLineParser; 24 | import org.apache.commons.cli.DefaultParser; 25 | import org.apache.commons.cli.Option; 26 | import org.apache.commons.cli.Options; 27 | import org.apache.commons.io.IOUtils; 28 | import org.apache.commons.io.output.NullOutputStream; 29 | 30 | @SuppressWarnings("restriction") 31 | public class ModuleSigner { 32 | 33 | private final PrivateKey privateKey; 34 | private final InputStream chainInputStream; 35 | 36 | public ModuleSigner(PrivateKey privateKey, InputStream chainInputStream) { 37 | this.privateKey = privateKey; 38 | this.chainInputStream = chainInputStream; 39 | } 40 | 41 | public void signModule(File moduleFileIn, File moduleFileOut) throws IOException { 42 | signModule(System.out, moduleFileIn, moduleFileOut); 43 | } 44 | 45 | public void signModule(PrintStream printStream, File moduleFileIn, File moduleFileOut) throws IOException { 46 | /** Filename -> Base64-encoded SHA256withRSA asymmetric signature of file contents. */ 47 | Properties signatures = new Properties(); 48 | 49 | ZipMap zipMap = new ZipMap(moduleFileIn); 50 | 51 | for (String fileName : zipMap.keySet()) { 52 | ZipMapFile file = zipMap.get(fileName); 53 | if (!file.isDirectory()) { 54 | fileName = "/" + fileName; 55 | printStream.println("--- signing ---"); 56 | printStream.println(fileName); 57 | 58 | try { 59 | byte[] sig = asymmetricSignature(privateKey, file.getBytes()); 60 | String b64 = Base64.getEncoder().encodeToString(sig); 61 | 62 | signatures.put(fileName, b64); 63 | 64 | printStream.println("signature: " + Arrays.toString(sig)); 65 | printStream.println("signature_b64: " + b64); 66 | } catch (GeneralSecurityException e) { 67 | throw new IOException("signing failed", e); 68 | } 69 | } 70 | } 71 | 72 | // Write out the signatures properties to the zip file 73 | StringWriter sw = new StringWriter(); 74 | PrintWriter pw = new PrintWriter(sw); 75 | signatures.store(pw, null); 76 | pw.flush(); 77 | pw.close(); 78 | 79 | zipMap.put("signatures.properties", sw.toString().getBytes()); 80 | 81 | // Write out the cert chain to the zip file 82 | ByteArrayOutputStream bos = new ByteArrayOutputStream(); 83 | IOUtils.copy(chainInputStream, bos); 84 | bos.flush(); 85 | 86 | ZipMapFile certFile = new ZipMapFile(bos.toByteArray(), false); 87 | zipMap.put("certificates.p7b", certFile); 88 | 89 | // Finally, write out the full signed module 90 | zipMap.writeToFile(moduleFileOut); 91 | } 92 | 93 | 94 | private static byte[] asymmetricSignature(PrivateKey privateKey, byte[] bs) throws GeneralSecurityException { 95 | Signature signature = Signature.getInstance("SHA256withRSA"); 96 | signature.initSign(privateKey); 97 | 98 | signature.update(bs); 99 | 100 | return signature.sign(); 101 | } 102 | 103 | public static class Main { 104 | 105 | public static final String OPT_KEY_STORE = "keystore"; 106 | public static final String OPT_KEY_STORE_PWD = "keystore-pwd"; 107 | public static final String OPT_ALIAS = "alias"; 108 | public static final String OPT_ALIAS_PWD = "alias-pwd"; 109 | public static final String OPT_CHAIN = "chain"; 110 | public static final String OPT_MODULE_IN = "module-in"; 111 | public static final String OPT_MODULE_OUT = "module-out"; 112 | public static final String OPT_PKCS11_CFG = "pkcs11-cfg"; 113 | public static final String OPT_VERBOSE = "verbose"; 114 | 115 | public static void main(String[] args) throws Exception { 116 | CommandLineParser parser = new DefaultParser(); 117 | CommandLine commandLine = parser.parse(makeOptions(), args); 118 | 119 | KeyStore keyStore; 120 | String keyStorePwd = commandLine.getOptionValue(OPT_KEY_STORE_PWD, ""); 121 | String alias = commandLine.getOptionValue(OPT_ALIAS); 122 | String aliasPwd = commandLine.getOptionValue(OPT_ALIAS_PWD, ""); 123 | 124 | if (commandLine.hasOption(OPT_PKCS11_CFG)) { 125 | Provider p = Security.getProvider("SunPKCS11"); 126 | p = p.configure(commandLine.getOptionValue(OPT_PKCS11_CFG)); 127 | Security.addProvider(p); 128 | keyStore = KeyStore.getInstance("PKCS11"); 129 | keyStore.load(null, keyStorePwd.toCharArray()); 130 | } else { 131 | File keyStoreFile = new File(commandLine.getOptionValue(OPT_KEY_STORE)); 132 | String keyStoreType = keyStoreFile.getCanonicalPath().endsWith("pfx") ? "pkcs12" : "jks"; 133 | 134 | keyStore = KeyStore.getInstance(keyStoreType); 135 | keyStore.load(new FileInputStream(keyStoreFile), keyStorePwd.toCharArray()); 136 | } 137 | 138 | Key privateKey = keyStore.getKey(alias, aliasPwd.toCharArray()); 139 | 140 | if (privateKey == null || !privateKey.getAlgorithm().equalsIgnoreCase("RSA")) { 141 | System.out.println("no RSA PrivateKey found for alias '" + alias + "'."); 142 | System.exit(-1); 143 | } 144 | 145 | InputStream chainInputStream = new FileInputStream(commandLine.getOptionValue(OPT_CHAIN)); 146 | 147 | File moduleIn = new File(commandLine.getOptionValue(OPT_MODULE_IN)); 148 | File moduleOut = new File(commandLine.getOptionValue(OPT_MODULE_OUT)); 149 | 150 | ModuleSigner moduleSigner = new ModuleSigner((PrivateKey) privateKey, chainInputStream); 151 | 152 | PrintStream printStream = commandLine.hasOption(OPT_VERBOSE) ? 153 | System.out : new PrintStream(NullOutputStream.NULL_OUTPUT_STREAM); 154 | 155 | moduleSigner.signModule(printStream, moduleIn, moduleOut); 156 | } 157 | 158 | private static Options makeOptions() { 159 | Option keyStore = Option.builder() 160 | .longOpt(OPT_KEY_STORE) 161 | .required(false) 162 | .hasArg() 163 | .build(); 164 | 165 | Option keyStorePassword = Option.builder() 166 | .longOpt(OPT_KEY_STORE_PWD) 167 | .hasArg() 168 | .build(); 169 | 170 | Option alias = Option.builder() 171 | .longOpt(OPT_ALIAS) 172 | .required() 173 | .hasArg() 174 | .build(); 175 | 176 | Option aliasPassword = Option.builder() 177 | .longOpt(OPT_ALIAS_PWD) 178 | .hasArg() 179 | .build(); 180 | 181 | Option chain = Option.builder() 182 | .longOpt(OPT_CHAIN) 183 | .required() 184 | .hasArg() 185 | .build(); 186 | 187 | Option moduleIn = Option.builder() 188 | .longOpt(OPT_MODULE_IN) 189 | .required() 190 | .hasArg() 191 | .build(); 192 | 193 | Option moduleOut = Option.builder() 194 | .longOpt(OPT_MODULE_OUT) 195 | .required() 196 | .hasArg() 197 | .build(); 198 | 199 | Option pkcs11Cfg = Option.builder() 200 | .longOpt(OPT_PKCS11_CFG) 201 | .required(false) 202 | .hasArg() 203 | .build(); 204 | 205 | Option verbose = Option.builder("v") 206 | .longOpt(OPT_VERBOSE) 207 | .required(false) 208 | .build(); 209 | 210 | return new Options() 211 | .addOption(keyStore) 212 | .addOption(keyStorePassword) 213 | .addOption(alias) 214 | .addOption(aliasPassword) 215 | .addOption(chain) 216 | .addOption(moduleIn) 217 | .addOption(moduleOut) 218 | .addOption(pkcs11Cfg) 219 | .addOption(verbose); 220 | } 221 | 222 | } 223 | 224 | } 225 | --------------------------------------------------------------------------------