diff --git a/README.md b/README.md index a7d5a74c..62f601c7 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ See https://ebourg.github.io/jsign for more information. * The Oracle Cloud signing service has been integrated * Signing of NuGet packages has been implemented (contributed by Sebastian Stamm) * Jsign now checks if the certificate subject matches the app manifest publisher before signing APPX/MSIX packages +* The `extract` command has been added to extract the signature from a signed file, in DER or PEM format * The JCA provider now works with [apksigner](https://developer.android.com/tools/apksigner) for signing Android applications * The APPX/MSIX bundles are now signed with the correct Authenticode UUID * The error message displayed when the password of a PKCS#12 keystore is missing has been fixed diff --git a/jsign-cli/src/main/java/net/jsign/JsignCLI.java b/jsign-cli/src/main/java/net/jsign/JsignCLI.java index c4fec79c..f593f61a 100644 --- a/jsign-cli/src/main/java/net/jsign/JsignCLI.java +++ b/jsign-cli/src/main/java/net/jsign/JsignCLI.java @@ -100,6 +100,11 @@ public static void main(String... args) { options.addOption(Option.builder("h").longOpt("help").desc("Print the help").build()); this.options.put("sign", options); + + options = new Options(); + options.addOption(Option.builder().hasArg().longOpt(PARAM_FORMAT).argName("FORMAT").desc(" The output format of the signature (DER or PEM)").build()); + + this.options.put("extract", options); } void execute(String... args) throws SignerException, ParseException { @@ -146,6 +151,7 @@ void execute(String... args) throws SignerException, ParseException { helper.replace(cmd.hasOption(PARAM_REPLACE)); setOption(PARAM_ENCODING, helper, cmd); helper.detached(cmd.hasOption(PARAM_DETACHED)); + setOption(PARAM_FORMAT, helper, cmd); if (cmd.getArgList().isEmpty()) { throw new SignerException("No file specified"); diff --git a/jsign-cli/src/test/java/net/jsign/JsignCLITest.java b/jsign-cli/src/test/java/net/jsign/JsignCLITest.java index dc742229..d6751968 100644 --- a/jsign-cli/src/test/java/net/jsign/JsignCLITest.java +++ b/jsign-cli/src/test/java/net/jsign/JsignCLITest.java @@ -543,4 +543,14 @@ public void testUnknownCommand() throws Exception { assertEquals("exception message", "Unknown command 'unsign'", e.getMessage()); } } + + @Test + public void testExtract() throws Exception { + try { + cli.execute("extract", "" + targetFile); + fail("No exception thrown"); + } catch (SignerException e) { + assertEquals("exception message", "No signature found in " + targetFile.getPath(), e.getMessage()); + } + } } diff --git a/jsign-core/src/main/java/net/jsign/SignerHelper.java b/jsign-core/src/main/java/net/jsign/SignerHelper.java index 87019820..e10d7ea4 100644 --- a/jsign-core/src/main/java/net/jsign/SignerHelper.java +++ b/jsign-core/src/main/java/net/jsign/SignerHelper.java @@ -17,6 +17,7 @@ package net.jsign; import java.io.File; +import java.io.FileWriter; import java.io.IOException; import java.net.Authenticator; import java.net.InetSocketAddress; @@ -33,6 +34,7 @@ import java.security.PrivateKey; import java.security.Provider; import java.security.cert.Certificate; +import java.util.Base64; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; @@ -76,6 +78,7 @@ class SignerHelper { public static final String PARAM_REPLACE = "replace"; public static final String PARAM_ENCODING = "encoding"; public static final String PARAM_DETACHED = "detached"; + public static final String PARAM_FORMAT = "format"; private final Console console; @@ -98,6 +101,7 @@ class SignerHelper { private boolean replace; private Charset encoding; private boolean detached; + private String format; private AuthenticodeSigner signer; @@ -222,6 +226,11 @@ public SignerHelper detached(boolean detached) { return this; } + public SignerHelper format(String format) { + this.format = format; + return this; + } + public SignerHelper param(String key, String value) { if (value == null) { return this; @@ -249,6 +258,7 @@ public SignerHelper param(String key, String value) { case PARAM_REPLACE: return replace("true".equalsIgnoreCase(value)); case PARAM_ENCODING: return encoding(value); case PARAM_DETACHED: return detached("true".equalsIgnoreCase(value)); + case PARAM_FORMAT: return format(value); default: throw new IllegalArgumentException("Unknown " + parameterName + ": " + key); } @@ -267,6 +277,9 @@ public void execute(File file) throws SignerException { case "sign": sign(file); break; + case "extract": + extract(file); + break; default: throw new SignerException("Unknown command '" + command + "'"); } @@ -443,14 +456,53 @@ private void attach(Signable signable, File detachedSignature) throws IOExceptio private void detach(Signable signable, File detachedSignature) throws IOException { CMSSignedData signedData = signable.getSignatures().get(0); byte[] content = signedData.toASN1Structure().getEncoded("DER"); - - FileUtils.writeByteArrayToFile(detachedSignature, content); + if (format == null || "DER".equalsIgnoreCase(format)) { + FileUtils.writeByteArrayToFile(detachedSignature, content); + } else if ("PEM".equalsIgnoreCase(format)) { + try (FileWriter out = new FileWriter(detachedSignature)) { + String encoded = Base64.getEncoder().encodeToString(content); + out.write("-----BEGIN PKCS7-----\n"); + for (int i = 0; i < encoded.length(); i += 64) { + out.write(encoded.substring(i, Math.min(i + 64, encoded.length()))); + out.write('\n'); + } + out.write("-----END PKCS7-----\n"); + } + } else { + throw new IllegalArgumentException("Unknown output format '" + format + "'"); + } } private File getDetachedSignature(File file) { return new File(file.getParentFile(), file.getName() + ".sig"); } + private void extract(File file) throws SignerException { + if (!file.exists()) { + throw new SignerException("Couldn't find " + file); + } + + try (Signable signable = Signable.of(file)) { + List signatures = signable.getSignatures(); + if (signatures.isEmpty()) { + throw new SignerException("No signature found in " + file); + } + + File detachedSignature = getDetachedSignature(file); + if ("PEM".equalsIgnoreCase(format)) { + detachedSignature = new File(detachedSignature.getParentFile(), detachedSignature.getName() + ".pem"); + } + console.info("Extracting signature to " + detachedSignature); + detach(signable, detachedSignature); + } catch (UnsupportedOperationException | IllegalArgumentException e) { + throw new SignerException(e.getMessage()); + } catch (SignerException e) { + throw e; + } catch (Exception e) { + throw new SignerException("Couldn't extract the signature from " + file, e); + } + } + /** * Initializes the proxy. * diff --git a/jsign-core/src/test/java/net/jsign/SignerHelperTest.java b/jsign-core/src/test/java/net/jsign/SignerHelperTest.java index 6e6c324f..2875d7f4 100644 --- a/jsign-core/src/test/java/net/jsign/SignerHelperTest.java +++ b/jsign-core/src/test/java/net/jsign/SignerHelperTest.java @@ -421,4 +421,99 @@ public void testUnknownCommand() { assertEquals("message", "Unknown command 'unsign'", e.getMessage()); } } + + @Test + public void testExtractDER() throws Exception { + File sourceFile = new File("target/test-classes/wineyes.exe"); + File targetFile = new File("target/test-classes/wineyes-signed.exe"); + + FileUtils.copyFile(sourceFile, targetFile); + + SignerHelper signer = new SignerHelper(new StdOutConsole(2), "parameter") + .keystore("target/test-classes/keystores/keystore.jks") + .keypass("password"); + + signer.execute(targetFile); + + signer.command("extract"); + signer.execute(targetFile); + + File signatureFile = new File("target/test-classes/wineyes-signed.exe.sig"); + assertTrue("Signature not extracted", signatureFile.exists()); + } + + @Test + public void testExtractPEM() throws Exception { + File sourceFile = new File("target/test-classes/wineyes.exe"); + File targetFile = new File("target/test-classes/wineyes-signed.exe"); + + FileUtils.copyFile(sourceFile, targetFile); + + SignerHelper signer = new SignerHelper(new StdOutConsole(2), "parameter") + .keystore("target/test-classes/keystores/keystore.jks") + .keypass("password"); + + signer.execute(targetFile); + + signer.command("extract"); + signer.format("PEM"); + signer.execute(targetFile); + + File signatureFile = new File("target/test-classes/wineyes-signed.exe.sig.pem"); + assertTrue("Signature not extracted", signatureFile.exists()); + } + + @Test + public void testExtractWithInvalidFormat() throws Exception { + File sourceFile = new File("target/test-classes/wineyes.exe"); + File targetFile = new File("target/test-classes/wineyes-signed.exe"); + + FileUtils.copyFile(sourceFile, targetFile); + + SignerHelper signer = new SignerHelper(new StdOutConsole(2), "parameter") + .keystore("target/test-classes/keystores/keystore.jks") + .keypass("password"); + + signer.execute(targetFile); + + signer.command("extract"); + signer.format("TXT"); + + try { + signer.execute(targetFile); + fail("No exception thrown"); + } catch (SignerException e) { + assertEquals("message", "Unknown output format 'TXT'", e.getMessage()); + } + } + + @Test + public void testExtractFromUnsignedFile() { + File file = new File("target/test-classes/wineyes.exe"); + + SignerHelper signer = new SignerHelper(new StdOutConsole(2), "parameter"); + signer.command("extract"); + + try { + signer.execute(file); + fail("No exception thrown"); + } catch (SignerException e) { + assertEquals("message", "No signature found in target/test-classes/wineyes.exe", e.getMessage().replace('\\', '/')); + } + } + + @Test + public void testExtractFromMissingFile() { + File file = new File("target/test-classes/xeyes.exe"); + + SignerHelper signer = new SignerHelper(new StdOutConsole(2), "parameter"); + signer.command("extract"); + + try { + signer.execute(file); + fail("No exception thrown"); + } catch (SignerException e) { + assertEquals("message", "Couldn't find target/test-classes/xeyes.exe", e.getMessage().replace('\\', '/')); + } + } }