SignerHelperTest.java

/*
 * Copyright 2021 Emmanuel Bourg
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package net.jsign;

import java.io.File;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.logging.Logger;
import java.util.zip.CRC32;

import org.apache.commons.io.FileUtils;
import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.cms.Attribute;
import org.bouncycastle.asn1.cms.CMSAttributes;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.SignerInformation;
import org.junit.Assume;
import org.junit.Test;

import net.jsign.asn1.authenticode.AuthenticodeObjectIdentifiers;
import net.jsign.jca.AWS;
import net.jsign.jca.Azure;
import net.jsign.jca.DigiCertONE;
import net.jsign.jca.GoogleCloud;
import net.jsign.jca.OracleCloudCredentials;
import net.jsign.jca.PIVCardTest;
import net.jsign.pe.PEFile;
import net.jsign.timestamp.TimestampingMode;

import static net.jsign.DigestAlgorithm.*;
import static org.junit.Assert.*;
import static org.junit.Assume.*;

public class SignerHelperTest {

    static {
        Logger.getLogger("net.jsign").setUseParentHandlers(false);
        Logger.getLogger("net.jsign").addHandler(new StdOutLogHandler());
    }

    @Test
    public void testDetachedSignature() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-detached.exe");

        File detachedSignatureFile = new File("target/test-classes/wineyes-signed-detached.exe.sig");
        detachedSignatureFile.delete();

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper signer = new SignerHelper("parameter")
                .keystore("target/test-classes/keystores/keystore.jks")
                .keypass("password");

        // sign and detach
        signer.execute(targetFile);

        assertFalse("Signature was detached", detachedSignatureFile.exists());

        signer.alg("SHA-512").detached(true);
        signer.execute(targetFile);

        assertTrue("Signature wasn't detached", detachedSignatureFile.exists());

        // attach the signature
        File targetFile2 = new File("target/test-classes/wineyes-signed-attached.exe");
        FileUtils.copyFile(sourceFile, targetFile2);
        File detachedSignatureFile2 = new File("target/test-classes/wineyes-signed-attached.exe.sig");
        detachedSignatureFile2.delete();
        detachedSignatureFile.renameTo(detachedSignatureFile2);

        signer = new SignerHelper("parameter").detached(true);
        signer.execute(targetFile2);

        assertEquals(FileUtils.checksum(targetFile, new CRC32()).getValue(), FileUtils.checksum(targetFile2, new CRC32()).getValue());
    }

    @Test
    public void testDetachedSignatureWithNotPaddedFile() throws Exception {
        File origFile = new File("target/test-classes/wineyes.exe");
        File sourceFile = new File("target/test-classes/wineyes-notpadded.exe");

        FileUtils.copyFile(origFile, sourceFile);

        // make the test file not padded on a 8 byte boundary
        SeekableByteChannel channel = Files.newByteChannel(sourceFile.toPath(), StandardOpenOption.READ, StandardOpenOption.WRITE);
        channel.truncate(channel.size() - 3);
        channel.close();

        File targetFile = new File("target/test-classes/wineyes-notpadded-signed-detached.exe");

        File detachedSignatureFile = new File("target/test-classes/wineyes-notpadded-signed-detached.exe.sig");
        detachedSignatureFile.delete();

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper signer = new SignerHelper("parameter")
                .keystore("target/test-classes/keystores/keystore.jks")
                .keypass("password")
                .detached(true);

        // sign and detach
        signer.execute(targetFile);

        assertTrue("Signature wasn't detached", detachedSignatureFile.exists());

        // attach the signature
        File targetFile2 = new File("target/test-classes/wineyes-notpadded-signed-attached.exe");
        FileUtils.copyFile(sourceFile, targetFile2);
        File detachedSignatureFile2 = new File("target/test-classes/wineyes-notpadded-signed-attached.exe.sig");
        detachedSignatureFile2.delete();
        detachedSignatureFile.renameTo(detachedSignatureFile2);

        signer = new SignerHelper("parameter").detached(true);
        signer.execute(targetFile2);

        assertEquals(FileUtils.checksum(targetFile, new CRC32()).getValue(), FileUtils.checksum(targetFile2, new CRC32()).getValue());
    }

    @Test
    public void testPasswordFromFile() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-with-external-password.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        Files.write(new File("target/test-classes/storepass.txt").toPath(), "password".getBytes());

        SignerHelper signer = new SignerHelper("parameter")
                .keystore("target/test-classes/keystores/keystore.jks")
                .keypass("file:target/test-classes/storepass.txt");

        signer.execute(targetFile);

        SignatureAssert.assertSigned(new PEFile(targetFile), SHA256);
    }

    @Test
    public void testPasswordFromFileFailed() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-with-external-password.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper signer = new SignerHelper("parameter")
                .keystore("target/test-classes/keystores/keystore.jks")
                .keypass("file:/path/to/missing/file");

        Exception e = assertThrows(SignerException.class, () -> signer.execute(targetFile));
        assertEquals("message", "Failed to read the keypass parameter from the file '/path/to/missing/file'", e.getMessage());
    }

    @Test
    public void testPasswordFromEnvironment() throws Exception {
        Assume.assumeTrue("STOREPASS environment variable not defined", System.getenv().containsKey("STOREPASS"));

        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-with-external-password.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper signer = new SignerHelper("parameter")
                .keystore("target/test-classes/keystores/keystore.jks")
                .keypass("env:STOREPASS");

        signer.execute(targetFile);

        SignatureAssert.assertSigned(new PEFile(targetFile), SHA256);
    }

    @Test
    public void testPasswordFromEnvironmentFailed() throws Exception {
        Assume.assumeFalse(System.getenv().containsKey("MISSING_VAR"));

        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-with-external-password.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper signer = new SignerHelper("parameter")
                .keystore("target/test-classes/keystores/keystore.jks")
                .keypass("env:MISSING_VAR");

        Exception e = assertThrows(SignerException.class, () -> signer.execute(targetFile));
        assertEquals("message", "Failed to read the keypass parameter, the 'MISSING_VAR' environment variable is not defined", e.getMessage());
    }

    @Test
    public void testAWS() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-with-aws.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper helper = new SignerHelper("option")
                .storetype("AWS")
                .keystore("eu-west-3")
                .storepass(AWS.getAccessKey() + "|" + AWS.getSecretKey())
                .alias("jsign")
                .certfile("src/test/resources/keystores/jsign-test-certificate-full-chain.pem")
                .alg("SHA-256");

        helper.execute(targetFile);

        SignatureAssert.assertSigned(new PEFile(targetFile), SHA256);
    }

    @Test
    public void testAzureKeyVault() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-with-signing-service.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper helper = new SignerHelper("option")
                .storetype("AZUREKEYVAULT")
                .keystore("jsignvault")
                .storepass(Azure.getAccessToken())
                .alias("jsign")
                .alg("SHA-256");

        helper.execute(targetFile);

        SignatureAssert.assertSigned(new PEFile(targetFile), SHA256);
    }

    @Test
    public void testGoogleCloud() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-with-signing-service.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper helper = new SignerHelper("option")
                .storetype("GOOGLECLOUD")
                .keystore("projects/fifth-glider-316809/locations/global/keyRings/jsignkeyring")
                .storepass(GoogleCloud.getAccessToken())
                .alias("test")
                .certfile("src/test/resources/keystores/jsign-test-certificate-full-chain-reversed.pem")
                .alg("SHA-256");

        helper.execute(targetFile);

        SignatureAssert.assertSigned(new PEFile(targetFile), SHA256);
    }

    @Test
    public void testDigiCertONE() throws Exception {
        String apikey = DigiCertONE.getApiKey();

        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-with-signing-service.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper helper = new SignerHelper("option")
                .storetype("DIGICERTONE")
                .storepass(apikey + "|" + DigiCertONE.getClientCertificateFile() + "|" + DigiCertONE.getClientCertificatePassword())
                .alias("Tomcat-PMC-cert-2021-11")
                .alg("SHA-256");

        helper.execute(targetFile);

        SignatureAssert.assertSigned(new PEFile(targetFile), SHA256);
    }

    @Test
    public void testESigner() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-with-signing-service.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper helper = new SignerHelper("option")
                .storetype("ESIGNER")
                .keystore("https://cs-try.ssl.com")
                .storepass("esigner_demo|esignerDemo#1")
                .alias("8b072e22-7685-4771-b5c6-48e46614915f")
                .keypass("RDXYgV9qju+6/7GnMf1vCbKexXVJmUVr+86Wq/8aIGg=")
                .alg("SHA-256");

        helper.execute(targetFile);

        SignatureAssert.assertSigned(new PEFile(targetFile), SHA256);
    }

    @Test
    public void testOracleCloud() throws Exception {
        Assume.assumeTrue("OCI configuration not found", OracleCloudCredentials.getConfigFile().exists());

        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-with-oci.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper helper = new SignerHelper("option")
                .storetype("ORACLECLOUD")
                .alias("ocid1.key.oc1.eu-paris-1.h5tafwboaahxq.abrwiljrwkhgllb5zfqchmvdkmqnzutqeq5pz7yo6z7yhl2zyn2yncwzxiza")
                .certfile("src/test/resources/keystores/jsign-test-certificate-full-chain.pem")
                .alg("SHA-256");

        helper.execute(targetFile);

        SignatureAssert.assertSigned(new PEFile(targetFile), SHA256);
    }

    @Test
    public void testTrustedSigning() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-with-azure-trusted-signing.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper helper = new SignerHelper("option")
                .storetype("TRUSTEDSIGNING")
                .keystore("weu.codesigning.azure.net")
                .storepass(Azure.getAccessToken("https://codesigning.azure.net"))
                .alias("MyAccount/MyProfile")
                .alg("SHA-256");

        helper.sign(targetFile);

        Signable signable = Signable.of(targetFile);
        SignatureAssert.assertSigned(signable, SHA256);
        SignatureAssert.assertTimestamped("Invalid timestamp", signable.getSignatures().get(0));
    }

    @Test
    public void testSignPath() throws Exception {
        String organization = System.getenv("SIGNPATH_ORGANIZATION_ID");
        String accessToken = System.getenv("SIGNPATH_API_TOKEN");
        assumeNotNull("SIGNPATH_ORGANIZATION_ID environment variable not defined", organization);
        assumeNotNull("SIGNPATH_API_TOKEN environment variable not defined", accessToken);

        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-with-signpath.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper helper = new SignerHelper("option")
                .storetype("SIGNPATH")
                .keystore(organization)
                .storepass(accessToken)
                .alias("jsign/rsa-2048")
                .alg("SHA-256");

        helper.sign(targetFile);

        Signable signable = Signable.of(targetFile);
        SignatureAssert.assertSigned(signable, SHA256);
    }

    @Test
    public void testPIV() throws Exception {
        PIVCardTest.assumeCardPresent();

        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-with-piv.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper helper = new SignerHelper("option")
                .storetype("PIV")
                .keystore("Yubikey")
                .storepass("123456")
                .alias("SIGNATURE")
                .certfile("src/test/resources/keystores/jsign-test-certificate-full-chain.pem")
                .alg("SHA-256");

        helper.execute(targetFile);

        SignatureAssert.assertSigned(new PEFile(targetFile), SHA256);
    }

    @Test
    public void testSignWithMismatchedKeyAlgorithms() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-mismatched-keys.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper signer = new SignerHelper("parameter")
                .keyfile("target/test-classes/keystores/privatekey-ec-p384.pkcs1.pem")
                .keypass("password")
                .certfile("target/test-classes/keystores/jsign-test-certificate-full-chain.pem");

        Exception e = assertThrows(SignerException.class, () -> signer.execute(targetFile));
        assertEquals("message", "Signature verification failed, the private key doesn't match the certificate", e.getCause().getMessage());

        SignatureAssert.assertSigned(Signable.of(targetFile));
    }

    @Test
    public void testSignWithMismatchedKeyLengths() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-mismatched-keys.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper signer = new SignerHelper("parameter")
                .keyfile("target/test-classes/keystores/privatekey.pkcs1.pem")
                .keypass("password")
                .certfile("target/test-classes/keystores/jsign-root-ca.pem");

        Exception e = assertThrows(SignerException.class, () -> signer.execute(targetFile));
        assertEquals("message", "Signature verification failed, the certificate is a root or intermediate CA certificate (CN=Jsign Root Certificate Authority 2024)", e.getCause().getMessage());

        SignatureAssert.assertSigned(Signable.of(targetFile));
    }

    @Test
    public void testSignWithMismatchedRSAKeys() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-mismatched-keys.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper signer = new SignerHelper("parameter")
                .keyfile("target/test-classes/keystores/privatekey.pkcs1.pem")
                .keypass("password")
                .certfile("target/test-classes/keystores/jsign-test-certificate-partial-chain.pem");

        Exception e = assertThrows(SignerException.class, () -> signer.execute(targetFile));
        assertEquals("message", "Signature verification failed, the certificate is a root or intermediate CA certificate (CN=Jsign Code Signing CA 2024)", e.getCause().getMessage());

        SignatureAssert.assertSigned(Signable.of(targetFile));
    }

    @Test
    public void testMissingPKCS12KeyStorePassword() {
        SignerHelper signer = new SignerHelper("parameter");
        signer.keystore("target/test-classes/keystores/keystore.p12");
        signer.alias("test");

        Exception e = assertThrows(SignerException.class, () -> signer.sign("target/test-classes/wineyes.exe"));
        assertEquals("message", "The keystore password must be specified", e.getMessage());
    }

    @Test
    public void testUnknownCommand() {
        SignerHelper signer = new SignerHelper("parameter");
        signer.command("unsign");

        Exception e = assertThrows(SignerException.class, () -> signer.execute("target/test-classes/wineyes.exe"));
        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("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("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("parameter")
                .keystore("target/test-classes/keystores/keystore.jks")
                .keypass("password");

        signer.execute(targetFile);

        signer.command("extract");
        signer.format("TXT");

        Exception e = assertThrows(SignerException.class, () -> signer.execute(targetFile));
        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("parameter");
        signer.command("extract");

        Exception e = assertThrows(SignerException.class, () -> signer.execute(file));
        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("parameter");
        signer.command("extract");

        Exception e = assertThrows(SignerException.class, () -> signer.execute(file));
        assertEquals("message", "Couldn't find target/test-classes/xeyes.exe", e.getMessage().replace('\\', '/'));
    }

    @Test
    public void testRemove() 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("parameter")
                .keystore("target/test-classes/keystores/keystore.jks")
                .keypass("password");

        signer.execute(targetFile);

        SignatureAssert.assertSigned(new PEFile(targetFile), SHA256);

        signer.command("remove");
        signer.execute(targetFile);

        SignatureAssert.assertNotSigned(new PEFile(targetFile));
    }

    @Test
    public void testRemoveFromUnsignedFile() 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("parameter");
        signer.command("remove");
        signer.execute(targetFile);

        SignatureAssert.assertNotSigned(new PEFile(targetFile));
    }

    @Test
    public void testRemoveFromMissingFile() {
        File file = new File("target/test-classes/xeyes.exe");

        SignerHelper signer = new SignerHelper("parameter");
        signer.command("remove");

        Exception e = assertThrows(SignerException.class, () -> signer.execute(file));
        assertEquals("message", "Couldn't find target/test-classes/xeyes.exe", e.getMessage().replace('\\', '/'));
    }

    @Test
    public void testTagWithString() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-tagged.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper signer = new SignerHelper("parameter")
                .keystore("target/test-classes/keystores/keystore.jks")
                .keypass("password");

        signer.execute(targetFile);

        signer.command("tag");
        signer.value("userid:1234-ABCD-5678-EFGH");
        signer.execute(targetFile);

        try (Signable signable = Signable.of(targetFile)) {
            CMSSignedData signature = signable.getSignatures().get(0);
            SignerInformation signerInfo = signature.getSignerInfos().getSigners().iterator().next();
            Attribute attribute = signerInfo.getUnsignedAttributes().get(AuthenticodeObjectIdentifiers.JSIGN_UNSIGNED_DATA_OBJID);
            assertNotNull("Unsigned attribute not found", attribute);
            assertEquals("Unsigned attribute value", "userid:1234-ABCD-5678-EFGH", attribute.getAttrValues().getObjectAt(0).toString());
        }
    }

    @Test
    public void testTagWithBinaryData() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-tagged.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper signer = new SignerHelper("parameter")
                .keystore("target/test-classes/keystores/keystore.jks")
                .keypass("password")
                .tsaurl("http://timestamp.digicert.com");

        signer.execute(targetFile);

        signer.command("tag");
        signer.value("0x414243444546");
        signer.execute(targetFile);

        try (Signable signable = Signable.of(targetFile)) {
            CMSSignedData signature = signable.getSignatures().get(0);
            SignerInformation signerInfo = signature.getSignerInfos().getSigners().iterator().next();
            Attribute attribute = signerInfo.getUnsignedAttributes().get(AuthenticodeObjectIdentifiers.JSIGN_UNSIGNED_DATA_OBJID);

            assertNotNull("Unsigned attribute not found", attribute);
            assertArrayEquals("Unsigned attribute value", "ABCDEF".getBytes(), ((DEROctetString) attribute.getAttrValues().getObjectAt(0)).getOctets());
        }
    }

    @Test
    public void testTagWithFileContent() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-tagged.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        File template = new File("target/test-classes/template.bin");
        Files.write(template.toPath(), "0123456".getBytes());

        SignerHelper signer = new SignerHelper("parameter")
                .keystore("target/test-classes/keystores/keystore.jks")
                .keypass("password");

        signer.execute(targetFile);

        signer.command("tag");
        signer.value("file:" + template.getAbsolutePath());
        signer.execute(targetFile);

        try (Signable signable = Signable.of(targetFile)) {
            CMSSignedData signature = signable.getSignatures().get(0);
            SignerInformation signerInfo = signature.getSignerInfos().getSigners().iterator().next();
            Attribute attribute = signerInfo.getUnsignedAttributes().get(AuthenticodeObjectIdentifiers.JSIGN_UNSIGNED_DATA_OBJID);

            assertNotNull("Unsigned attribute not found", attribute);
            assertArrayEquals("Unsigned attribute value", "0123456".getBytes(), ((DEROctetString) attribute.getAttrValues().getObjectAt(0)).getOctets());
        }
    }

    @Test
    public void testTagWithMissingFile() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-tagged.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper signer = new SignerHelper("parameter")
                .keystore("target/test-classes/keystores/keystore.jks")
                .keypass("password");

        signer.execute(targetFile);

        signer.command("tag");
        signer.value("file:missing-template.bin");

        Exception e = assertThrows(SignerException.class, () -> signer.execute(targetFile));
        assertEquals("message", "Couldn't modify the signature of target/test-classes/wineyes-signed-tagged.exe", e.getMessage().replace('\\', '/'));
    }

    @Test
    public void testTagWithDefaultTemplate() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-tagged.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper signer = new SignerHelper("parameter")
                .keystore("target/test-classes/keystores/keystore.jks")
                .keypass("password");

        signer.execute(targetFile);

        signer.command("tag");
        signer.execute(targetFile);

        try (Signable signable = Signable.of(targetFile)) {
            CMSSignedData signature = signable.getSignatures().get(0);
            SignerInformation signerInfo = signature.getSignerInfos().getSigners().iterator().next();
            Attribute attribute = signerInfo.getUnsignedAttributes().get(AuthenticodeObjectIdentifiers.JSIGN_UNSIGNED_DATA_OBJID);

            assertNotNull("Unsigned attribute not found", attribute);

            String value = new String(((DEROctetString) attribute.getAttrValues().getObjectAt(0)).getOctets());
            assertTrue("Unsigned attribute value", value.startsWith("-----BEGIN TAG-----"));
            assertTrue("Unsigned attribute value", value.endsWith("-----END TAG-----"));
        }
    }

    @Test
    public void testTagUnsignedFile() {
        File file = new File("target/test-classes/wineyes.exe");

        SignerHelper signer = new SignerHelper("parameter");
        signer.command("tag");

        Exception e = assertThrows(SignerException.class, () -> signer.execute(file));
        assertEquals("message", "No signature found in target/test-classes/wineyes.exe", e.getMessage().replace('\\', '/'));
    }

    @Test
    public void testTagMissingFile() {
        File file = new File("target/test-classes/xeyes.exe");

        SignerHelper signer = new SignerHelper("parameter");
        signer.command("tag");

        Exception e = assertThrows(SignerException.class, () -> signer.execute(file));
        assertEquals("message", "Couldn't find target/test-classes/xeyes.exe", e.getMessage().replace('\\', '/'));
    }

    @Test
    public void testTimestamp() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-signed-then-timestamped.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper signer = new SignerHelper("parameter")
                .keystore("target/test-classes/keystores/keystore.jks")
                .keypass("password")
                .tsmode(TimestampingMode.AUTHENTICODE.name());

        signer.execute(targetFile);

        signer = new SignerHelper("parameter")
                .keystore("target/test-classes/keystores/keystore.jks")
                .keypass("password");

        signer.execute(targetFile);
        signer.execute(targetFile);

        try (Signable signable = Signable.of(targetFile)) {
            SignatureAssert.assertTimestamped("Invalid timestamp", signable.getSignatures().get(0));
            SignatureAssert.assertNotTimestamped("Unexpected timestamp", signable.getSignatures().get(1));
            SignatureAssert.assertNotTimestamped("Unexpected timestamp", signable.getSignatures().get(2));
        }

        signer.command("timestamp");
        signer.execute(targetFile);

        try (Signable signable = Signable.of(targetFile)) {
            SignatureAssert.assertTimestamped("Invalid timestamp", signable.getSignatures().get(0));
            SignatureAssert.assertTimestamped("Invalid timestamp", signable.getSignatures().get(1));
            SignatureAssert.assertTimestamped("Invalid timestamp", signable.getSignatures().get(2));
        }
    }

    @Test
    public void testReplaceTimestamp() throws Exception {
        File sourceFile = new File("target/test-classes/wineyes.exe");
        File targetFile = new File("target/test-classes/wineyes-timestamp-replaced.exe");

        FileUtils.copyFile(sourceFile, targetFile);

        SignerHelper signer = new SignerHelper("parameter")
                .keystore("target/test-classes/keystores/keystore.jks")
                .keypass("password")
                .tsaurl("http://timestamp.sectigo.com")
                .tsmode(TimestampingMode.AUTHENTICODE.name());

        signer.execute(targetFile);
        try (Signable signable = Signable.of(targetFile)) {
            SignatureAssert.assertTimestamped("Invalid timestamp", signable.getSignatures().get(0));
        }

        signer = new SignerHelper("parameter");
        signer.command("timestamp");
        signer.tsaurl("http://timestamp.sectigo.com");
        signer.tsmode(TimestampingMode.AUTHENTICODE.name());
        signer.replace(true);
        signer.execute(targetFile);

        try (Signable signable = Signable.of(targetFile)) {
            CMSSignedData signature = signable.getSignatures().get(0);
            SignatureAssert.assertTimestamped("Invalid timestamp", signature);
            SignerInformation signerInformation = signature.getSignerInfos().iterator().next();
            assertNull("old timestamp not removed", signerInformation.getUnsignedAttributes().get(AuthenticodeObjectIdentifiers.SPC_RFC3161_OBJID));
            assertNotNull("missing new timestamp", signerInformation.getUnsignedAttributes().get(CMSAttributes.counterSignature));
        }
    }

    @Test
    public void testTimestampUnsignedFile() {
        File file = new File("target/test-classes/wineyes.exe");

        SignerHelper signer = new SignerHelper("parameter");
        signer.command("timestamp");

        Exception e = assertThrows(SignerException.class, () -> signer.execute(file));
        assertEquals("message", "No signature found in target/test-classes/wineyes.exe", e.getMessage().replace('\\', '/'));
    }

    @Test
    public void testTimestampMissingFile() {
        File file = new File("target/test-classes/xeyes.exe");

        SignerHelper signer = new SignerHelper("parameter");
        signer.command("timestamp");

        Exception e = assertThrows(SignerException.class, () -> signer.execute(file));
        assertEquals("message", "Couldn't find target/test-classes/xeyes.exe", e.getMessage().replace('\\', '/'));
    }
}