AuthenticodeSigner.java

/*
 * Copyright 2019 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.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.PublicKey;
import java.security.Security;
import java.security.SignatureException;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;

import org.bouncycastle.asn1.ASN1Encodable;
import org.bouncycastle.asn1.DERNull;
import org.bouncycastle.asn1.DERSet;
import org.bouncycastle.asn1.cms.Attribute;
import org.bouncycastle.asn1.cms.AttributeTable;
import org.bouncycastle.asn1.cms.CMSAttributes;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaCertStore;
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
import org.bouncycastle.cms.CMSAttributeTableGenerator;
import org.bouncycastle.cms.CMSException;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.CMSSignedDataGenerator;
import org.bouncycastle.cms.CMSTypedData;
import org.bouncycastle.cms.DefaultCMSSignatureEncryptionAlgorithmFinder;
import org.bouncycastle.cms.DefaultSignedAttributeTableGenerator;
import org.bouncycastle.cms.SignerInfoGenerator;
import org.bouncycastle.cms.SignerInfoGeneratorBuilder;
import org.bouncycastle.cms.SignerInformationVerifier;
import org.bouncycastle.cms.jcajce.JcaSignerInfoVerifierBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.DefaultDigestAlgorithmIdentifierFinder;
import org.bouncycastle.operator.DigestCalculatorProvider;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;

import net.jsign.asn1.authenticode.AuthenticodeObjectIdentifiers;
import net.jsign.asn1.authenticode.AuthenticodeSignedDataGenerator;
import net.jsign.asn1.authenticode.FilteredAttributeTableGenerator;
import net.jsign.asn1.authenticode.SpcSpOpusInfo;
import net.jsign.asn1.authenticode.SpcStatementType;
import net.jsign.jca.SigningServiceJcaProvider;
import net.jsign.nuget.NugetFile;
import net.jsign.timestamp.Timestamper;
import net.jsign.timestamp.TimestampingMode;

/**
 * Sign a file with Authenticode. Timestamping is enabled by default and relies
 * on the Sectigo server (http://timestamp.sectigo.com).
 *
 * <p>Example:</p>
 * <pre>
 * KeyStore keystore = new KeyStoreBuilder().keystore("keystore.p12").storepass("password").build();
 *
 * AuthenticodeSigner signer = new AuthenticodeSigner(keystore, "alias", "secret");
 * signer.withProgramName("My Application")
 *       .withProgramURL("http://www.example.com")
 *       .withTimestamping(true)
 *       .withTimestampingAuthority("http://timestamp.sectigo.com");
 *
 * try (Signable file = Signable.of(new File("application.exe"))) {
 *     signer.sign(file);
 * }
 * </pre>
 *
 * @author Emmanuel Bourg
 * @since 3.0
 */
public class AuthenticodeSigner {

    protected Certificate[] chain;
    protected PrivateKey privateKey;
    protected DigestAlgorithm digestAlgorithm = DigestAlgorithm.getDefault();
    protected String signatureAlgorithm;
    protected Provider signatureProvider;
    protected String programName;
    protected String programURL;
    protected boolean replace;
    protected boolean timestamping = true;
    protected TimestampingMode tsmode = TimestampingMode.AUTHENTICODE;
    protected String[] tsaurlOverride;
    protected Timestamper timestamper;
    protected int timestampingRetries = -1;
    protected int timestampingRetryWait = -1;

    /**
     * Create a signer with the specified certificate chain and private key.
     *
     * @param chain       the certificate chain. The first certificate is the signing certificate
     * @param privateKey  the private key
     * @throws IllegalArgumentException if the chain is empty
     */
    public AuthenticodeSigner(Certificate[] chain, PrivateKey privateKey) {
        this.chain = chain;
        this.privateKey = privateKey;
        
        if (chain == null || chain.length == 0) {
            throw new IllegalArgumentException("The certificate chain is empty");
        }
    }

    /**
     * Create a signer with a certificate chain and private key from the specified keystore.
     *
     * @param keystore the keystore holding the certificate and the private key
     * @param alias    the alias of the certificate in the keystore
     * @param password the password to get the private key
     * @throws KeyStoreException if the keystore has not been initialized (loaded).
     * @throws NoSuchAlgorithmException if the algorithm for recovering the key cannot be found
     * @throws UnrecoverableKeyException if the key cannot be recovered (e.g., the given password is wrong).
     */
    public AuthenticodeSigner(KeyStore keystore, String alias, String password) throws NoSuchAlgorithmException, KeyStoreException, UnrecoverableKeyException {
        Certificate[] chain = keystore.getCertificateChain(alias);
        if (chain == null) {
            throw new IllegalArgumentException("No certificate found in the keystore with the alias '" + alias + "'");
        }
        this.chain = chain;
        this.privateKey = (PrivateKey) keystore.getKey(alias, password != null ? password.toCharArray() : null);

        Provider provider = keystore.getProvider();
        if (provider.getName().startsWith("SunPKCS11") || provider instanceof SigningServiceJcaProvider) {
            this.signatureProvider = provider;
        }
    }

    /**
     * Set the program name embedded in the signature.
     * 
     * @param programName the program name
     * @return the current signer
     */
    public AuthenticodeSigner withProgramName(String programName) {
        this.programName = programName;
        return this;
    }

    /**
     * Set the program URL embedded in the signature.
     * 
     * @param programURL the program URL
     * @return the current signer
     */
    public AuthenticodeSigner withProgramURL(String programURL) {
        this.programURL = programURL;
        return this;
    }

    /**
     * Enable or disable the replacement of the previous signatures (disabled by default).
     * 
     * @param replace <code>true</code> if the new signature should replace the existing ones, <code>false</code> to append it
     * @return the current signer
     * @since 2.0
     */
    public AuthenticodeSigner withSignaturesReplaced(boolean replace) {
        this.replace = replace;
        return this;
    }

    /**
     * Enable or disable the timestamping (enabled by default).
     * 
     * @param timestamping <code>true</code> to enable timestamping, <code>false</code> to disable it
     * @return the current signer
     */
    public AuthenticodeSigner withTimestamping(boolean timestamping) {
        this.timestamping = timestamping;
        return this;
    }

    /**
     * RFC3161 or Authenticode (Authenticode by default).
     * 
     * @param tsmode the timestamping mode
     * @return the current signer
     * @since 1.3
     */
    public AuthenticodeSigner withTimestampingMode(TimestampingMode tsmode) {
        this.tsmode = tsmode;
        return this;
    }

    /**
     * Set the URL of the timestamping authority. Both RFC 3161 (as used for jar signing)
     * and Authenticode timestamping services are supported.
     * 
     * @param url the URL of the timestamping authority
     * @return the current signer
     * @since 2.1
     */
    public AuthenticodeSigner withTimestampingAuthority(String url) {
        return withTimestampingAuthority(new String[] { url });
    }

    /**
     * Set the URLs of the timestamping authorities. Both RFC 3161 (as used for jar signing)
     * and Authenticode timestamping services are supported.
     * 
     * @param urls the URLs of the timestamping authorities
     * @return the current signer
     * @since 2.1
     */
    public AuthenticodeSigner withTimestampingAuthority(String... urls) {
        this.tsaurlOverride = urls;
        return this;
    }

    /**
     * Set the Timestamper implementation.
     * 
     * @param timestamper the timestamper implementation to use
     * @return the current signer
     */
    public AuthenticodeSigner withTimestamper(Timestamper timestamper) {
        this.timestamper = timestamper;
        return this;
    }

    /**
     * Set the number of retries for timestamping.
     * 
     * @param timestampingRetries the number of retries
     * @return the current signer
     */
    public AuthenticodeSigner withTimestampingRetries(int timestampingRetries) {
        this.timestampingRetries = timestampingRetries;
        return this;
    }

    /**
     * Set the number of seconds to wait between timestamping retries.
     * 
     * @param timestampingRetryWait the wait time between retries (in seconds)
     * @return the current signer
     */
    public AuthenticodeSigner withTimestampingRetryWait(int timestampingRetryWait) {
        this.timestampingRetryWait = timestampingRetryWait;
        return this;
    }

    /**
     * Set the digest algorithm to use (SHA-256 by default)
     * 
     * @param algorithm the digest algorithm
     * @return the current signer
     */
    public AuthenticodeSigner withDigestAlgorithm(DigestAlgorithm algorithm) {
        if (algorithm != null) {
            this.digestAlgorithm = algorithm;
        }
        return this;
    }

    /**
     * Explicitly sets the signature algorithm to use.
     * 
     * @param signatureAlgorithm the signature algorithm
     * @return the current signer
     * @since 2.0
     */
    public AuthenticodeSigner withSignatureAlgorithm(String signatureAlgorithm) {
        this.signatureAlgorithm = signatureAlgorithm;
        return this;
    }

    /**
     * Returns the signature algorithm to use.
     */
    private String getSignatureAlgorithm() {
        if (signatureAlgorithm != null) {
            return signatureAlgorithm;
        } else if ("EC".equals(privateKey.getAlgorithm())) {
            return digestAlgorithm + "withECDSA";
        } else if ("EdDSA".equals(privateKey.getAlgorithm())) {
            X509Certificate certificate = (X509Certificate) chain[0];
            PublicKey publicKey = certificate.getPublicKey();
            if (publicKey.toString().contains("Ed25519")) {
                return "Ed25519";
            } else if (publicKey.toString().contains("Ed448")) {
                return "Ed448";
            }
            // return ((EdECKey) publicKey).getParams().getName(); // todo with Java 15+
        }

        return digestAlgorithm + "with" + privateKey.getAlgorithm();
    }

    /**
     * Explicitly sets the signature algorithm and provider to use.
     * 
     * @param signatureAlgorithm the signature algorithm
     * @param signatureProvider the security provider for the specified algorithm
     * @return the current signer
     * @since 2.0
     */
    public AuthenticodeSigner withSignatureAlgorithm(String signatureAlgorithm, String signatureProvider) {
        return withSignatureAlgorithm(signatureAlgorithm, Security.getProvider(signatureProvider));
    }

    /**
     * Explicitly sets the signature algorithm and provider to use.
     * 
     * @param signatureAlgorithm the signature algorithm
     * @param signatureProvider the security provider for the specified algorithm
     * @return the current signer
     * @since 2.0
     */
    public AuthenticodeSigner withSignatureAlgorithm(String signatureAlgorithm, Provider signatureProvider) {
        this.signatureAlgorithm = signatureAlgorithm;
        this.signatureProvider = signatureProvider;
        return this;
    }

    /**
     * Set the signature provider to use.
     * 
     * @param signatureProvider the security provider for the signature algorithm
     * @return the current signer
     * @since 2.0
     */
    public AuthenticodeSigner withSignatureProvider(Provider signatureProvider) {
        this.signatureProvider = signatureProvider;
        return this;
    }

    /**
     * Sign the specified file.
     *
     * @param file the file to sign
     * @throws Exception if signing fails
     */
    public void sign(Signable file) throws Exception {
        file.validate(chain[0]);

        if (file instanceof NugetFile && !replace) {
            List<CMSSignedData> signatures = file.getSignatures();
            if (!signatures.isEmpty()) {
                throw new SignerException("The file is already signed, the existing signature must be replaced explicitly");
            }
        }

        CMSSignedData sigData = createSignedData(file);
        
        if (!replace) {
            List<CMSSignedData> signatures = file.getSignatures();
            if (!signatures.isEmpty()) {
                // append the nested signature
                sigData = SignatureUtils.addNestedSignature(signatures.get(0), false, sigData);
            }
        }
        
        file.setSignature(sigData);
        file.save();
    }

    /**
     * Create the PKCS7 message with the signature and the timestamp.
     * 
     * @param file the file to sign
     * @return the PKCS7 message with the signature and the timestamp
     * @throws Exception if an error occurs
     */
    protected CMSSignedData createSignedData(Signable file) throws Exception {
        // compute the signature
        CMSTypedData contentInfo = file.createSignedContent(digestAlgorithm);
        CMSSignedDataGenerator generator = createSignedDataGenerator(file, contentInfo);
        CMSSignedData sigData = generator.generate(contentInfo, true);
        
        // verify the signature
        verify(sigData);
        
        // timestamping
        if (timestamping) {
            Timestamper ts = timestamper;
            if (ts == null) {
                boolean authenticode = AuthenticodeObjectIdentifiers.isAuthenticode(sigData.getSignedContentTypeOID());
                ts = Timestamper.create(authenticode ? tsmode : TimestampingMode.RFC3161);
            }
            if (tsaurlOverride != null) {
                ts.setURLs(tsaurlOverride);
            }
            if (timestampingRetries != -1) {
                ts.setRetries(timestampingRetries);
            }
            if (timestampingRetryWait != -1) {
                ts.setRetryWait(timestampingRetryWait);
            }
            sigData = ts.timestamp(digestAlgorithm, sigData);
        }
        
        return sigData;
    }

    private CMSSignedDataGenerator createSignedDataGenerator(Signable file, CMSTypedData contentInfo) throws CMSException, OperatorCreationException, CertificateEncodingException {
        List<X509Certificate> fullChain = CertificateUtils.getFullCertificateChain((Collection) Arrays.asList(chain));
        fullChain.removeIf(CertificateUtils::isSelfSigned);

        boolean authenticode = AuthenticodeObjectIdentifiers.isAuthenticode(contentInfo.getContentType().getId());
        CMSSignedDataGenerator generator = authenticode ? new AuthenticodeSignedDataGenerator() : new CMSSignedDataGenerator();
        generator.addCertificates(new JcaCertStore(fullChain));
        generator.addSignerInfoGenerator(createSignerInfoGenerator(file, authenticode));

        return generator;
    }

    private SignerInfoGenerator createSignerInfoGenerator(Signable file, boolean authenticode) throws OperatorCreationException, CertificateEncodingException {
        // create content signer
        JcaContentSignerBuilder contentSignerBuilder = new JcaContentSignerBuilder(getSignatureAlgorithm());
        if (signatureProvider != null) {
            contentSignerBuilder.setProvider(signatureProvider);
        }
        ContentSigner shaSigner = contentSignerBuilder.build(privateKey);

        DigestCalculatorProvider digestCalculatorProvider = new JcaDigestCalculatorProviderBuilder().build();
        
        // prepare the authenticated attributes
        List<Attribute> attributes = new ArrayList<>(authenticode ? createAuthenticatedAttributes() : file.createSignedAttributes((X509Certificate) chain[0]));
        AttributeTable attributeTable = new AttributeTable(new DERSet(attributes.toArray(new ASN1Encodable[0])));
        CMSAttributeTableGenerator attributeTableGenerator = new DefaultSignedAttributeTableGenerator(attributeTable);
        if (authenticode) {
            attributeTableGenerator = new FilteredAttributeTableGenerator(attributeTableGenerator, CMSAttributes.cmsAlgorithmProtect, CMSAttributes.signingTime);
        } else {
            attributeTableGenerator = new FilteredAttributeTableGenerator(attributeTableGenerator, CMSAttributes.cmsAlgorithmProtect);
        }
        
        // fetch the signing certificate
        X509CertificateHolder certificate = new JcaX509CertificateHolder((X509Certificate) chain[0]);
        
        // prepare the signerInfo with the extra authenticated attributes
        SignerInfoGeneratorBuilder signerInfoGeneratorBuilder = new SignerInfoGeneratorBuilder(digestCalculatorProvider, new DefaultCMSSignatureEncryptionAlgorithmFinder(){
            @Override
            public AlgorithmIdentifier findEncryptionAlgorithm(final AlgorithmIdentifier signatureAlgorithm) {
                //enforce "RSA" instead of "shaXXXRSA" for digest signature to be more like signtool
                if (signatureAlgorithm.getAlgorithm().equals(PKCSObjectIdentifiers.sha256WithRSAEncryption) ||
                    signatureAlgorithm.getAlgorithm().equals(PKCSObjectIdentifiers.sha384WithRSAEncryption) ||
                    signatureAlgorithm.getAlgorithm().equals(PKCSObjectIdentifiers.sha512WithRSAEncryption)) {
                    return new AlgorithmIdentifier(PKCSObjectIdentifiers.rsaEncryption, DERNull.INSTANCE);
                } else {
                    return super.findEncryptionAlgorithm(signatureAlgorithm);
                }
            }
        });
        signerInfoGeneratorBuilder.setSignedAttributeGenerator(attributeTableGenerator);
        signerInfoGeneratorBuilder.setContentDigest(createContentDigestAlgorithmIdentifier(shaSigner.getAlgorithmIdentifier()));
        return signerInfoGeneratorBuilder.build(shaSigner, certificate);
    }

    private void verify(CMSSignedData signedData) throws SignatureException, OperatorCreationException {
        X509Certificate certificate = (X509Certificate) chain[0];
        PublicKey publicKey = certificate.getPublicKey();
        DigestCalculatorProvider digestCalculatorProvider = new JcaDigestCalculatorProviderBuilder().build();
        SignerInformationVerifier verifier = new JcaSignerInfoVerifierBuilder(digestCalculatorProvider).build(publicKey);

        boolean result = false;
        Throwable cause = null;
        try {
            result = signedData.verifySignatures(signerId -> verifier, false);
        } catch (Exception e) {
            cause = e;
            while (cause.getCause() != null) {
                cause = cause.getCause();
            }
        }

        if (!result) {
            boolean ca = certificate.getBasicConstraints() != -1;
            String message = "Signature verification failed, ";
            if (ca) {
                message += "the certificate is a root or intermediate CA certificate (" + certificate.getSubjectX500Principal() + ")";
            } else {
                message += "the private key doesn't match the certificate";
            }
            throw new SignatureException(message, cause);
        }
    }

    /**
     * Creates the authenticated attributes for the SignerInfo section of the signature.
     * 
     * @return the authenticated attributes
     */
    private List<Attribute> createAuthenticatedAttributes() {
        List<Attribute> attributes = new ArrayList<>();
        
        SpcStatementType spcStatementType = new SpcStatementType(AuthenticodeObjectIdentifiers.SPC_INDIVIDUAL_SP_KEY_PURPOSE_OBJID);
        attributes.add(new Attribute(AuthenticodeObjectIdentifiers.SPC_STATEMENT_TYPE_OBJID, new DERSet(spcStatementType)));
        
        if (programName != null || programURL != null) {
            SpcSpOpusInfo spcSpOpusInfo = new SpcSpOpusInfo(programName, programURL);
            attributes.add(new Attribute(AuthenticodeObjectIdentifiers.SPC_SP_OPUS_INFO_OBJID, new DERSet(spcSpOpusInfo)));
        }

        return attributes;
    }

    /**
     * Create the digest algorithm identifier to use as content digest.
     * By default looks up the default identifier but also makes sure it includes
     * the algorithm parameters and if not includes a DER NULL in order to align
     * with what signtool currently does.
     *
     * @param signatureAlgorithm to get the corresponding digest algorithm identifier for
     * @return an AlgorithmIdentifier for the digestAlgorithm and including parameters
     */
    protected AlgorithmIdentifier createContentDigestAlgorithmIdentifier(AlgorithmIdentifier signatureAlgorithm) {
        if ("1.3.101.112".equals(signatureAlgorithm.getAlgorithm().getId()) // Ed25519
            || "1.3.101.113".equals(signatureAlgorithm.getAlgorithm().getId())) { // Ed448
            return new AlgorithmIdentifier(digestAlgorithm.oid, DERNull.INSTANCE);
        }

        AlgorithmIdentifier ai = new DefaultDigestAlgorithmIdentifierFinder().find(signatureAlgorithm);
        if (ai.getParameters() == null) {
            // Always include parameters to align with what signtool does
            ai = new AlgorithmIdentifier(ai.getAlgorithm(), DERNull.INSTANCE);
        }
        return ai;
    }
}