JarsignerSignMojo.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.apache.maven.plugins.jarsigner;

import java.io.File;
import java.io.IOException;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.stream.Collectors;

import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.plugins.jarsigner.TsaSelector.TsaServer;
import org.apache.maven.shared.jarsigner.JarSigner;
import org.apache.maven.shared.jarsigner.JarSignerRequest;
import org.apache.maven.shared.jarsigner.JarSignerSignRequest;
import org.apache.maven.shared.jarsigner.JarSignerUtil;
import org.apache.maven.shared.utils.StringUtils;
import org.apache.maven.shared.utils.cli.Commandline;
import org.apache.maven.shared.utils.cli.javatool.JavaToolException;
import org.apache.maven.shared.utils.cli.javatool.JavaToolResult;

/**
 * Signs a project artifact and attachments using jarsigner.
 *
 * @author <a href="cs@schulte.it">Christian Schulte</a>
 * @since 1.0
 */
@Mojo(name = "sign", defaultPhase = LifecyclePhase.PACKAGE, threadSafe = true)
public class JarsignerSignMojo extends AbstractJarsignerMojo {

    /**
     * See <a href="https://docs.oracle.com/javase/7/docs/technotes/tools/windows/jarsigner.html#Options">options</a>.
     */
    @Parameter(property = "jarsigner.keypass")
    private String keypass;

    /**
     * See <a href="https://docs.oracle.com/javase/7/docs/technotes/tools/windows/jarsigner.html#Options">options</a>.
     */
    @Parameter(property = "jarsigner.sigfile")
    private String sigfile;

    /**
     * Indicates whether existing signatures should be removed from the processed JAR files prior to signing them. If
     * enabled, the resulting JAR will appear as being signed only once.
     *
     * @since 1.1
     */
    @Parameter(property = "jarsigner.removeExistingSignatures", defaultValue = "false")
    private boolean removeExistingSignatures;

    /**
     * <p>URL(s) to Time Stamping Authority (TSA) server(s) to use to timestamp the signing.
     * See <a href="https://docs.oracle.com/javase/7/docs/technotes/tools/windows/jarsigner.html#Options">options</a>.
     * Separate multiple TSA URLs with comma (without space) or a nested XML tag.</p>
     *
     * <pre>{@code
     * <configuration>
     *   <tsa>http://timestamp.digicert.com,http://timestamp.globalsign.com/tsa/r6advanced1</tsa>
     * </configuration>
     * }</pre>
     *
     * <pre>{@code
     * <configuration>
     *   <tsa>
     *     <url>http://timestamp.digicert.com</url>
     *     <url>http://timestamp.globalsign.com/tsa/r6advanced1</url>
     *   </tsa>
     * </configuration>
     * }</pre>
     *
     * <p>Usage of multiple TSA servers only makes sense when {@link #maxTries} is more than 1. A different TSA server
     * will only be used at retries.</p>
     *
     * <p>Changed to a list since 3.1.0. Single XML element (without comma) is still supported.</p>
     *
     * @since 1.3
     */
    @Parameter(property = "jarsigner.tsa")
    private String[] tsa;

    /**
     * <p>Alias(es) for certificate(s) in the active keystore used to find a TSA URL. From the certificate the X509v3
     * extension "Subject Information Access" field is examined to find the TSA server URL. See
     * <a href="https://docs.oracle.com/javase/7/docs/technotes/tools/windows/jarsigner.html#Options">options</a>.
     * Separate multiple aliases with comma (without space) or a nested XML tag.</p>
     *
     * <pre>{@code
     * <configuration>
     *   <tsacert>alias1,alias2</tsacert>
     * </configuration>
     * }</pre>
     *
     * <pre>{@code
     * <configuration>
     *   <tsacert>
     *     <alias>alias1</alias>
     *     <alias>alias2</alias>
     *   </tsacert>
     * </configuration>
     * }</pre>
     *
     * <p>Should not be used at the same time as the {@link #tsa} parameter (because jarsigner will typically ignore
     * tsacert, if tsa is set).</p>
     *
     * <p>Usage of multiple aliases only makes sense when {@link #maxTries} is more than 1. A different TSA server
     * will only be used at retries.</p>
     *
     * <p>Changed to a list since 3.1.0. Single XML element (without comma) is still supported.</p>
     *
     * @since 1.3
     */
    @Parameter(property = "jarsigner.tsacert")
    private String[] tsacert;

    /**
     * <p>OID(s) to send to the TSA server to identify the policy ID the server should use. If not specified TSA server
     * will choose a default policy ID. Each TSA server vendor will typically define their own policy OIDs. See
     * <a href="https://docs.oracle.com/javase/8/docs/technotes/tools/windows/jarsigner.html#CCHIFIAD">options</a>.
     * Separate multiple OIDs with comma (without space) or a nested XML tag.</p>
     *
     * <pre>{@code
     * <configuration>
     *   <tsapolicyid>1.3.6.1.4.1.4146.2.3.1.2,2.16.840.1.114412.7.1</tsapolicyid>
     * </configuration>
     * }</pre>
     *
     * <pre>{@code
     * <configuration>
     *   <tsapolicyid>
     *     <oid>1.3.6.1.4.1.4146.2.3.1.2</oid>
     *     <oid>2.16.840.1.114412.7.1</oid>
     *   </tsapolicyid>
     * </configuration>
     * }</pre>
     *
     * <p>If used, the number of OIDs should be the same as the number of elements in {@link #tsa} or {@link #tsacert}.
     * The first OID will be used for the first TSA server, the second OID for the second TSA server and so on.</p>
     *
     * @since 3.1.0
     */
    @Parameter(property = "jarsigner.tsapolicyid")
    private String[] tsapolicyid;

    /**
     * The message digest algorithm to use in the messageImprint that the TSA server will timestamp. A default value
     * (for example {@code SHA-384}) will be selected by jarsigner if this parameter is not set. Only available in
     * Java 11 and later. See <a href="https://docs.oracle.com/en/java/javase/11/tools/jarsigner.html">options</a>.
     *
     * @since 3.1.0
     */
    @Parameter(property = "jarsigner.tsadigestalg")
    private String tsadigestalg;

    /**
     * Location of the extra certificate chain file. See
     * <a href="https://docs.oracle.com/javase/7/docs/technotes/tools/windows/jarsigner.html#Options">options</a>.
     *
     * @since 1.5
     */
    @Parameter(property = "jarsigner.certchain", required = false)
    private File certchain;

    /**
     * How many times to try to sign a jar (assuming each previous attempt is a failure). This option may be desirable
     * if any network operations are used during signing, for example using a Time Stamp Authority or network based
     * PKCS11 HSM solution for storing code signing keys.
     *
     * The default value of 1 indicates that no retries should be made.
     *
     * @since 3.1.0
     */
    @Parameter(property = "jarsigner.maxTries", defaultValue = "1")
    private int maxTries;

    /**
     * Maximum delay, in seconds, to wait after a failed attempt before re-trying. The delay after a failed attempt
     * follows an exponential backoff strategy, with increasing delay times.
     *
     * @since 3.1.0
     */
    @Parameter(property = "jarsigner.maxRetryDelaySeconds", defaultValue = "0")
    private int maxRetryDelaySeconds;

    /**
     * Maximum number of parallel threads to use when signing jar files. Increases performance when signing multiple jar
     * files, especially when network operations are used during signing, for example when using a Time Stamp Authority
     * or network based PKCS11 HSM solution for storing code signing keys. Note: the logging from the signing process
     * will be interleaved, and harder to read, when using many threads.
     *
     * @since 3.1.0
     */
    @Parameter(property = "jarsigner.threadCount", defaultValue = "1")
    private int threadCount;

    /** Current WaitStrategy, to allow for sleeping after a signing failure. */
    private WaitStrategy waitStrategy = this::defaultWaitStrategy;

    private TsaSelector tsaSelector;

    /** Exponent limit for exponential wait after failure function. 2^20 = 1048576 sec ~= 12 days. */
    private static final int MAX_WAIT_EXPONENT_ATTEMPT = 20;

    @Override
    protected String getCommandlineInfo(final Commandline commandLine) {
        String commandLineInfo = commandLine != null ? commandLine.toString() : null;

        if (commandLineInfo != null) {
            commandLineInfo = StringUtils.replace(commandLineInfo, this.keypass, "'*****'");
        }

        return commandLineInfo;
    }

    @Override
    protected void preProcessArchive(final File archive) throws MojoExecutionException {
        if (removeExistingSignatures) {
            try {
                JarSignerUtil.unsignArchive(archive);
            } catch (IOException e) {
                throw new MojoExecutionException("Failed to unsign archive " + archive + ": " + e.getMessage(), e);
            }
        }
    }

    @Override
    protected void validateParameters() throws MojoExecutionException {
        super.validateParameters();

        if (maxTries < 1) {
            getLog().warn(getMessage("invalidMaxTries", maxTries));
            maxTries = 1;
        }

        if (maxRetryDelaySeconds < 0) {
            getLog().warn(getMessage("invalidMaxRetryDelaySeconds", maxRetryDelaySeconds));
            maxRetryDelaySeconds = 0;
        }

        if (threadCount < 1) {
            getLog().warn(getMessage("invalidThreadCount", threadCount));
            threadCount = 1;
        }

        if (tsa.length > 0 && tsacert.length > 0) {
            getLog().warn(getMessage("warnUsageTsaAndTsacertSimultaneous"));
        }
        if (tsapolicyid.length > tsa.length || tsapolicyid.length > tsacert.length) {
            getLog().warn(getMessage("warnUsageTsapolicyidTooMany", tsapolicyid.length, tsa.length, tsacert.length));
        }
        if (tsa.length > 1 && maxTries == 1) {
            getLog().warn(getMessage("warnUsageMultiTsaWithoutRetry", tsa.length));
        }
        if (tsacert.length > 1 && maxTries == 1) {
            getLog().warn(getMessage("warnUsageMultiTsacertWithoutRetry", tsacert.length));
        }
        tsaSelector = new TsaSelector(tsa, tsacert, tsapolicyid, tsadigestalg);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    protected JarSignerRequest createRequest(File archive) throws MojoExecutionException {
        JarSignerSignRequest request = new JarSignerSignRequest();
        request.setSigfile(sigfile);
        updateJarSignerRequestWithTsa(request, tsaSelector.getServer());
        request.setCertchain(certchain);

        // Special handling for passwords through the Maven Security Dispatcher
        request.setKeypass(decrypt(keypass));
        return request;
    }

    /** Modifies JarSignerRequest with TSA parameters */
    private void updateJarSignerRequestWithTsa(JarSignerSignRequest request, TsaServer tsaServer) {
        request.setTsaLocation(tsaServer.getTsaUrl());
        request.setTsaAlias(tsaServer.getTsaAlias());
        request.setTsapolicyid(tsaServer.getTsaPolicyId());
        request.setTsadigestalg(tsaServer.getTsaDigestAlt());
    }

    /**
     * {@inheritDoc} Processing of files may be parallelized for increased performance.
     */
    @Override
    protected void processArchives(List<File> archives) throws MojoExecutionException {
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        List<Future<Void>> futures = archives.stream()
                .map(file -> executor.submit((Callable<Void>) () -> {
                    processArchive(file);
                    return null; // Return dummy value to conform with Void type
                }))
                .collect(Collectors.toList());
        try {
            for (Future<Void> future : futures) {
                future.get(); // Wait for completion. Result ignored, but may raise any Exception
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new MojoExecutionException("Thread interrupted while waiting for jarsigner to complete", e);
        } catch (ExecutionException e) {
            if (e.getCause() instanceof MojoExecutionException) {
                throw (MojoExecutionException) e.getCause();
            }
            throw new MojoExecutionException("Error processing archives", e);
        } finally {
            // Shutdown of thread pool. If an Exception occurred, remaining threads will be aborted "best effort"
            executor.shutdownNow();
        }
    }

    /**
     * {@inheritDoc}
     *
     * Will retry signing up to maxTries times if it fails.
     *
     * @throws MojoExecutionException if all signing attempts fail
     */
    @Override
    protected void executeJarSigner(JarSigner jarSigner, JarSignerRequest request)
            throws JavaToolException, MojoExecutionException {
        for (int attempt = 0; attempt < maxTries; attempt++) {
            JavaToolResult result = jarSigner.execute(request);
            int resultCode = result.getExitCode();
            if (resultCode == 0) {
                return;
            }
            tsaSelector.registerFailure(); // Could be TSA server problem or something unrelated to TSA

            if (attempt < maxTries - 1) { // If not last attempt
                waitStrategy.waitAfterFailure(attempt, Duration.ofSeconds(maxRetryDelaySeconds));
                updateJarSignerRequestWithTsa((JarSignerSignRequest) request, tsaSelector.getServer());
            } else {
                // Last attempt failed, use this failure as resulting failure
                throw new MojoExecutionException(
                        getMessage("failure", getCommandlineInfo(result.getCommandline()), resultCode));
            }
        }
    }

    /** Set current WaitStrategy. Package private for testing. */
    void setWaitStrategy(WaitStrategy waitStrategy) {
        this.waitStrategy = waitStrategy;
    }

    /** Wait/sleep after a signing failure before the next re-try should happen. */
    @FunctionalInterface
    interface WaitStrategy {
        /**
         * Will be called after a signing failure, if a re-try is about to happen. May as a side effect sleep current
         * thread for some time.
         *
         * @param attempt the attempt number (0 is the first)
         * @param maxRetryDelay the maximum duration to sleep (may be zero)
         * @throws MojoExecutionException if the sleep was interrupted
         */
        void waitAfterFailure(int attempt, Duration maxRetryDelay) throws MojoExecutionException;
    }

    private void defaultWaitStrategy(int attempt, Duration maxRetryDelay) throws MojoExecutionException {
        waitAfterFailure(attempt, maxRetryDelay, Thread::sleep);
    }

    /** Thread.sleep(long millis) interface to make testing easier */
    @FunctionalInterface
    interface Sleeper {
        void sleep(long millis) throws InterruptedException;
    }

    /** Package private for testing */
    void waitAfterFailure(int attempt, Duration maxRetryDelay, Sleeper sleeper) throws MojoExecutionException {
        // Use attempt as exponent in the exponential function, but limit it to avoid too big values.
        int exponentAttempt = Math.min(attempt, MAX_WAIT_EXPONENT_ATTEMPT);
        long delayMillis = (long) (Duration.ofSeconds(1).toMillis() * Math.pow(2, exponentAttempt));
        delayMillis = Math.min(delayMillis, maxRetryDelay.toMillis());
        if (delayMillis > 0) {
            getLog().info("Sleeping after failed attempt for " + (delayMillis / 1000) + " seconds...");
            try {
                sleeper.sleep(delayMillis);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new MojoExecutionException("Thread interrupted while waiting after failure", e);
            }
        }
    }
}