JsignTool.java
/*
* Copyright 2026 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.io.FileWriter;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.PublicKey;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.security.interfaces.ECPublicKey;
import java.security.interfaces.RSAPublicKey;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.logging.Logger;
import java.util.stream.Stream;
import org.apache.commons.collections4.functors.AndPredicate;
import org.bouncycastle.asn1.ASN1Encodable;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.ASN1UTF8String;
import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.DERUTF8String;
import org.bouncycastle.asn1.x500.RDN;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x500.style.BCStyle;
import org.bouncycastle.asn1.x500.style.IETFUtils;
import org.bouncycastle.asn1.x509.DigestInfo;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cms.CMSException;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.SignerId;
import org.bouncycastle.cms.SignerInformation;
import org.bouncycastle.operator.DefaultAlgorithmNameFinder;
import org.bouncycastle.util.encoders.Hex;
import net.jsign.asn1.authenticode.AuthenticodeObjectIdentifiers;
import net.jsign.asn1.authenticode.SpcLink;
import net.jsign.asn1.authenticode.SpcSpOpusInfo;
import net.jsign.timestamp.Timestamper;
import net.jsign.timestamp.TimestampingMode;
import net.jsign.verify.CheckResult;
import net.jsign.verify.SignatureVerifier;
import net.jsign.verify.VerificationException;
import net.jsign.verify.VerificationResult;
/**
* High level API for executing Jsign commands.
*
* <p>Examples:</p>
*
* <pre>
* JsignTool.sign()
* .keystore("keystore.p12")
* .storepass("password")
* .alias("test")
* .name("My Application")
* .tsaurl("http://timestamp.sectigo.com")
* .execute(file);
*
* JsignTool.tag().value("userid:1234-ABCD-5678-EFGH").execute(file);
*
* JsignTool.extract().format("PEM").execute(file);
*
* JsignTool.remove().execute(file);
* </pre>
*
* @since 8.0
*/
public final class JsignTool {
JsignTool() {
}
/**
* Creates a builder for the {@code sign} command.
*/
public static Sign sign() {
return new JsignTool().new Sign("parameter");
}
/**
* Creates a builder for the {@code tag} command.
*/
public static Tag tag() {
return new JsignTool().new Tag();
}
/**
* Creates a builder for the {@code extract} command.
*/
public static Extract extract() {
return new JsignTool().new Extract();
}
/**
* Creates a builder for the {@code timestamp} command.
*/
public static Timestamp<?> timestamp() {
return new JsignTool().new Timestamp<>();
}
/**
* Creates a builder for the {@code remove} command.
*/
public static Remove remove() {
return new JsignTool().new Remove();
}
/**
* Creates a builder for the {@code show} command.
*/
public static Show show() {
return new JsignTool().new Show();
}
/**
* Creates a builder for the {@code verify} command.
*/
public static Verify verify() {
return new JsignTool().new Verify();
}
abstract class Command<T extends Command<?>> {
protected final Logger log = Logger.getLogger(getClass().getName());
File basedir;
/** The name used to refer to a configuration parameter */
String parameterName = "parameter";
public Command() {
}
/**
* Resets the state of the command after a parameter changed.
*/
void reset() {
}
T basedir(File basedir) {
this.basedir = basedir;
return (T) this;
}
File createFile(String file) {
if (file == null) {
return null;
}
if (new File(file).isAbsolute()) {
return new File(file);
} else {
return new File(basedir, file);
}
}
/**
* Executes the command on the specified files.
*
* @param files the files to execute the command on
*/
public void execute(File... files) throws CommandException {
execute(Stream.of(files));
}
/**
* Executes the command on the specified files.
*
* @param files the files to execute the command on
*/
public void execute(Stream<File> files) throws CommandException {
Iterator<File> it = files.iterator();
if (!it.hasNext()) {
throw new IllegalArgumentException("No file specified");
}
while (it.hasNext()) {
execute(it.next());
}
}
abstract void execute(File file) throws CommandException;
}
/**
* Base class for commands requiring network access.
*/
abstract class ProxyCommand<T extends ProxyCommand<?>> extends Command<T> {
protected ProxySettings proxySettings = new ProxySettings();
void initializeProxy() throws CommandException {
try {
proxySettings.initializeProxy();
} catch (Exception e) {
throw new CommandException("Couldn't initialize proxy", e);
}
}
/**
* Sets the URL of the HTTP proxy.
*/
public T proxyUrl(String proxyUrl) {
proxySettings.url = proxyUrl;
reset();
return (T) this;
}
/**
* Sets the user for the HTTP proxy.
*/
public T proxyUser(String proxyUser) {
proxySettings.username = proxyUser;
reset();
return (T) this;
}
/**
* Sets the password for the HTTP proxy user.
*/
public T proxyPass(String proxyPass) {
proxySettings.password = proxyPass;
reset();
return (T) this;
}
/**
* Sets the hosts that bypass the HTTP proxy.
*/
public T nonProxyHosts(String nonProxyHosts) {
proxySettings.nonProxyHosts = nonProxyHosts;
reset();
return (T) this;
}
}
/**
* Command for signing files.
*/
public class Sign extends Timestamp<Sign> {
private AuthenticodeSigner signer;
private final KeyStoreBuilder ksparams;
private String alias;
private String name;
private String url;
private boolean lazy;
private Charset encoding;
private boolean detached;
Sign(String parameterName) {
this.parameterName = parameterName;
this.ksparams = new KeyStoreBuilder(parameterName);
}
@Override
Sign basedir(File basedir) {
ksparams.setBaseDir(basedir);
return (Sign) super.basedir(basedir);
}
void reset() {
signer = null;
}
@Override
public Sign replace(boolean replace) {
super.replace(replace);
return this;
}
@Override
public Sign replace() {
super.replace();
reset();
return this;
}
/**
* Sets the keystore file, the SunPKCS11 configuration file, the cloud keystore name, or the card/token name.
*/
public Sign keystore(String keystore) {
ksparams.keystore(keystore);
reset();
return this;
}
/**
* Sets the keystore file or the SunPKCS11 configuration file.
*/
public Sign keystore(File keystore) {
ksparams.keystore(keystore);
reset();
return this;
}
/**
* Sets the password to open the keystore.
*/
public Sign storepass(String storepass) {
ksparams.storepass(storepass);
reset();
return this;
}
/**
* Sets the type of the keystore.
*/
public Sign storetype(KeyStoreType storetype) {
ksparams.storetype(storetype);
reset();
return this;
}
/**
* Sets the alias of the certificate used for signing in the keystore.
*/
public Sign alias(String alias) {
this.alias = alias;
reset();
return this;
}
/**
* Sets the password of the private key. When using a keystore, this parameter can be omitted if the keystore shares the same password.
*/
public Sign keypass(String keypass) {
ksparams.keypass(keypass);
reset();
return this;
}
/**
* Sets the file containing the private key. PEM and PVK files are supported.
*/
public Sign keyfile(String keyfile) {
ksparams.keyfile(keyfile);
reset();
return this;
}
/**
* Sets the file containing the private key. PEM and PVK files are supported.
*/
public Sign keyfile(File keyfile) {
ksparams.keyfile(keyfile);
reset();
return this;
}
/**
* Sets the file containing the PKCS#7 certificate chain (.p7b or .spc files).
*/
public Sign certfile(String certfile) {
ksparams.certfile(certfile);
reset();
return this;
}
/**
* Sets the file containing the PKCS#7 certificate chain (.p7b or .spc files).
*/
public Sign certfile(File certfile) {
ksparams.certfile(certfile);
reset();
return this;
}
@Override
public Sign alg(DigestAlgorithm alg) {
reset();
return super.alg(alg);
}
@Override
public Sign alg(String alg) {
reset();
return super.alg(alg);
}
/**
* Sets the name of the application.
*/
public Sign name(String name) {
this.name = name;
reset();
return this;
}
/**
* Sets the URL of the application.
*/
public Sign url(String url) {
this.url = url;
reset();
return this;
}
/**
* Skip files that are already signed.
*/
public Sign lazy() {
return lazy(true);
}
/**
* Skip files that are already signed.
*/
public Sign lazy(boolean lazy) {
this.lazy = lazy;
return this;
}
/**
* Sets the encoding of the script to be signed (UTF-8 by default, or the encoding specified by the byte order mark if there is one).
*/
public Sign encoding(String encoding) {
this.encoding = encoding != null ? Charset.forName(encoding) : null;
return this;
}
/**
* Tells that a detached signature should be generated or reused.
*/
public Sign detached() {
return detached(true);
}
/**
* Tells if a detached signature should be generated or reused.
*/
public Sign detached(boolean detached) {
this.detached = detached;
return this;
}
private AuthenticodeSigner build() throws CommandException {
initializeProxy();
KeyStore ks;
try {
ks = ksparams.build();
} catch (KeyStoreException e) {
throw new CommandException("Failed to load the keystore " + (ksparams.keystore() != null ? ksparams.keystore() : ""), e);
}
KeyStoreType storetype = ksparams.storetype();
Provider provider = ksparams.provider();
Set<String> aliases = null;
if (alias == null) {
// guess the alias if there is only one in the keystore
try {
aliases = storetype.getAliases(ks);
} catch (KeyStoreException e) {
throw new CommandException(e.getMessage(), e);
}
if (aliases.isEmpty()) {
throw new CommandException("No certificate found in the keystore " + (provider != null ? provider.getName() : ksparams.keystore()));
} else if (aliases.size() == 1) {
alias = aliases.iterator().next();
} else {
throw new CommandException("alias " + parameterName + " must be set to select a certificate (available aliases: " + String.join(", ", aliases) + ")");
}
}
Certificate[] chain;
if (ksparams.certfile() != null) {
// replace the certificate chain from the keystore with the complete chain from file
try {
chain = CertificateChain.load(ksparams.certfile()).toArray();
} catch (Exception e) {
throw new CommandException("Failed to load the certificate from " + ksparams.certfile(), e);
}
} else {
try {
chain = ks.getCertificateChain(alias);
} catch (KeyStoreException e) {
throw new CommandException(e.getMessage(), e);
}
if (chain == null) {
String message = "No certificate found under the alias '" + alias + "' in the keystore " + (provider != null ? provider.getName() : ksparams.keystore());
if (aliases == null) {
try {
aliases = new LinkedHashSet<>(Collections.list(ks.aliases()));
if (aliases.isEmpty()) {
message = "No certificate found in the keystore " + (provider != null ? provider.getName() : ksparams.keystore());
} else if (aliases.contains(alias)) {
message = "The keystore password must be specified";
} else {
message += " (available aliases: " + String.join(", ", aliases) + ")";
}
} catch (KeyStoreException e) {
message += " (couldn't load the list of available aliases: " + e.getMessage() + ")";
}
}
throw new CommandException(message);
}
}
String keypass = ksparams.keypass();
char[] password = keypass != null ? keypass.toCharArray() : new char[0];
PrivateKey privateKey;
try {
privateKey = (PrivateKey) ks.getKey(alias, password);
} catch (Exception e) {
throw new CommandException("Failed to retrieve the private key from the keystore", e);
}
if (alg != null && DigestAlgorithm.of(alg) == null) {
throw new CommandException("The digest algorithm " + alg + " is not supported");
}
// enable timestamping with Azure Artifact Signing
if (tsaurl == null && storetype == KeyStoreType.TRUSTEDSIGNING) {
tsaurl = "http://timestamp.acs.microsoft.com/";
tsmode = TimestampingMode.RFC3161;
tsretries = 3;
}
// configure the signer
return new AuthenticodeSigner(chain, privateKey)
.withProgramName(name)
.withProgramURL(url)
.withDigestAlgorithm(DigestAlgorithm.of(alg))
.withSignatureProvider(provider)
.withSignaturesReplaced(replace)
.withTimestamping(tsaurl != null || tsmode != null)
.withTimestampingMode(tsmode != null ? tsmode : TimestampingMode.AUTHENTICODE)
.withTimestampingRetries(tsretries)
.withTimestampingRetryWait(tsretrywait)
.withTimestampingAuthority(tsaurl != null ? tsaurl.split(",") : null);
}
void execute(File file) throws CommandException {
if (file == null) {
throw new CommandException("No file specified");
}
if (!file.exists()) {
throw new CommandException("The file " + file + " couldn't be found");
}
try (Signable signable = Signable.of(file, encoding)) {
File detachedSignature = getDetachedSignature(file);
if (detached && detachedSignature.exists()) {
try {
log.info("Attaching Authenticode signature to " + file);
attach(signable, detachedSignature);
} catch (Exception e) {
throw new CommandException("Couldn't attach the signature to " + file, e);
}
} else {
if (lazy && !signable.getSignatures().isEmpty()) {
log.info("Skipping already signed file " + file);
return;
}
if (signer == null) {
signer = build();
}
log.info("Adding Authenticode signature to " + file);
signer.sign(signable);
if (detached) {
detach(signable, detachedSignature, "DER");
}
}
} catch (UnsupportedOperationException | IllegalArgumentException e) {
throw new CommandException(e.getMessage(), e);
} catch (CommandException e) {
throw e;
} catch (Exception e) {
throw new CommandException("Couldn't sign " + file, e);
}
}
}
/**
* Command for adding a timestamp to a signed file.
*/
public class Timestamp<T extends Timestamp<?>> extends ProxyCommand<T> {
String tsaurl;
TimestampingMode tsmode;
int tsretries = -1;
int tsretrywait = -1;
String alg;
boolean replace;
/**
* Sets the digest algorithm.
*/
public T alg(String alg) {
this.alg = alg;
return (T) this;
}
/**
* Sets the digest algorithm.
*/
public T alg(DigestAlgorithm alg) {
this.alg = alg != null ? alg.name() : null;
return (T) this;
}
/**
* Sets the URL of the timestamping authority.
* Several URLs separated by a comma can be specified to fallback on alternative servers.
*/
public T tsaurl(String tsaurl) {
this.tsaurl = tsaurl;
reset();
return (T) this;
}
/**
* Sets the timestamping mode ({@link TimestampingMode#AUTHENTICODE} or {@link TimestampingMode#RFC3161}).
*/
public T tsmode(TimestampingMode tsmode) {
this.tsmode = tsmode;
reset();
return (T) this;
}
/**
* Sets the number of retries for timestamping.
*/
public T tsretries(int tsretries) {
this.tsretries = tsretries;
reset();
return (T) this;
}
/**
* Sets the number of seconds to wait between timestamping retries.
*/
public T tsretrywait(int tsretrywait) {
this.tsretrywait = tsretrywait;
reset();
return (T) this;
}
/**
* Tells if the previous timestamps should be replaced.
*/
public Timestamp<?> replace(boolean replace) {
this.replace = replace;
return this;
}
/**
* Tells that the previous timestamps should be replaced.
*/
public Timestamp<?> replace() {
return replace(true);
}
void execute(File file) throws CommandException {
if (!file.exists()) {
throw new CommandException("Couldn't find " + file);
}
initializeProxy();
try (Signable signable = Signable.of(file)) {
if (signable.getSignatures().isEmpty()) {
throw new CommandException("No signature found in " + file);
}
Timestamper timestamper = Timestamper.create(tsmode != null ? tsmode : TimestampingMode.AUTHENTICODE);
timestamper.setRetries(tsretries);
timestamper.setRetryWait(tsretrywait);
if (tsaurl != null) {
timestamper.setURLs(tsaurl.split(","));
}
DigestAlgorithm digestAlgorithm = alg != null ? DigestAlgorithm.of(alg) : DigestAlgorithm.getDefault();
List<CMSSignedData> signatures = new ArrayList<>();
for (CMSSignedData signature : signable.getSignatures()) {
SignerInformation signerInformation = signature.getSignerInfos().iterator().next();
SignerId signerId = signerInformation.getSID();
X509CertificateHolder certificate = (X509CertificateHolder) signature.getCertificates().getMatches(signerId).iterator().next();
String digestAlgorithmName = new DefaultAlgorithmNameFinder().getAlgorithmName(signerInformation.getDigestAlgorithmID());
String keyAlgorithmName = new DefaultAlgorithmNameFinder().getAlgorithmName(new ASN1ObjectIdentifier(signerInformation.getEncryptionAlgOID()));
String name = digestAlgorithmName + "/" + keyAlgorithmName + " signature from '" + certificate.getSubject() + "'";
if (SignatureUtils.isTimestamped(signature) && !replace) {
log.fine(name + " already timestamped");
signatures.add(signature);
continue;
}
boolean expired = certificate.getNotAfter().before(new Date());
if (expired) {
log.fine(name + " is expired, skipping");
signatures.add(signature);
continue;
}
log.info("Adding timestamp to " + name);
signature = SignatureUtils.removeTimestamp(signature);
signature = timestamper.timestamp(digestAlgorithm, signature);
signatures.add(signature);
}
signable.setSignatures(signatures);
signable.save();
} catch (IOException | CMSException e) {
throw new CommandException("Couldn't timestamp " + file, e);
}
}
}
/**
* Command for tagging a signed file with an unauthenticated attribute.
*/
public class Tag extends Command {
private String value;
/**
* Sets the value of the unsigned attribute. The value is either:
* <ul>
* <li>a string (such as a user id, a license key or a JWT token)</li>
* <li>the name of the file to include, prefixed with <code>file:</code>
* <li>a binary value in hexadecimal format, prefixed with <code>0x</code>
* </ul>
*/
public Tag value(String value) {
this.value = value;
return this;
}
void execute(File file) throws CommandException {
if (!file.exists()) {
throw new CommandException("Couldn't find " + file);
}
try (Signable signable = Signable.of(file)) {
List<CMSSignedData> signatures = signable.getSignatures();
if (signatures.isEmpty()) {
throw new CommandException("No signature found in " + file);
}
log.info("Adding tag to " + file);
signatures.set(0, SignatureUtils.addUnsignedAttribute(signatures.get(0), AuthenticodeObjectIdentifiers.JSIGN_UNSIGNED_DATA_OBJID, getTagValue()));
signable.setSignatures(signatures);
signable.save();
} catch (CommandException e) {
throw e;
} catch (Exception e) {
throw new CommandException("Couldn't modify the signature of " + file, e);
}
}
private ASN1Encodable getTagValue() throws IOException {
if (value == null) {
byte[] array = new byte[1024];
String begin = "-----BEGIN TAG-----";
System.arraycopy(begin.getBytes(), 0, array, 0, begin.length());
String end = "-----END TAG-----";
System.arraycopy(end.getBytes(), 0, array, array.length - end.length(), end.length());
return new DEROctetString(array);
} else if (value.startsWith("0x")) {
byte[] array = Hex.decode(value.substring(2));
return new DEROctetString(array);
} else if (value.startsWith("file:")) {
byte[] array = Files.readAllBytes(new File(value.substring("file:".length())).toPath());
return new DEROctetString(array);
} else {
return new DERUTF8String(value);
}
}
}
/**
* Command for extracting the signatures of a signed file.
*/
public class Extract extends Command {
private String format;
/**
* Sets the output format of the signature ({@code DER} or {@code PEM}).
*/
public Extract format(String format) {
this.format = format;
return this;
}
void execute(File file) throws CommandException {
if (!file.exists()) {
throw new CommandException("Couldn't find " + file);
}
try (Signable signable = Signable.of(file)) {
List<CMSSignedData> signatures = signable.getSignatures();
if (signatures.isEmpty()) {
throw new CommandException("No signature found in " + file);
}
File detachedSignature = getDetachedSignature(file);
if ("PEM".equalsIgnoreCase(format)) {
detachedSignature = new File(detachedSignature.getParentFile(), detachedSignature.getName() + ".pem");
}
log.info("Extracting signature to " + detachedSignature);
detach(signable, detachedSignature, format);
} catch (UnsupportedOperationException | IllegalArgumentException e) {
throw new CommandException(e.getMessage(), e);
} catch (CommandException e) {
throw e;
} catch (Exception e) {
throw new CommandException("Couldn't extract the signature from " + file, e);
}
}
}
/**
* Command for removing the signatures of a file.
*/
public class Remove extends Command {
private String alg;
private String name;
/**
* Sets the digest algorithm of the signatures to remove.
*/
public Remove alg(DigestAlgorithm alg) {
this.alg = alg.name();
return this;
}
/**
* Sets the digest algorithm of the signatures to remove.
*/
public Remove alg(String alg) {
this.alg = alg;
return this;
}
/**
* Sets the certificate name used to select the signatures to remove (partial match).
*/
public Remove name(String name) {
this.name = name;
return this;
}
public void execute(File file) throws CommandException {
if (!file.exists()) {
throw new CommandException("Couldn't find " + file);
}
try (Signable signable = Signable.of(file)) {
List<CMSSignedData> signatures = signable.getSignatures();
if (signatures.isEmpty()) {
log.severe("No signature found in " + file);
return;
}
DigestAlgorithm removedAlgorithm = alg != null ? DigestAlgorithm.of(alg) : null;
if (alg != null && removedAlgorithm == null) {
throw new CommandException("The digest algorithm " + alg + " is not supported");
}
int signatureCount = signatures.size();
signatures.removeIf(new AndPredicate<>(
signature -> alg == null || signature.getSignerInfos().iterator().next().getDigestAlgOID().equals(removedAlgorithm.oid.getId()),
signature -> {
SignerInformation signer = signature.getSignerInfos().iterator().next();
X509CertificateHolder cert = (X509CertificateHolder) signature.getCertificates().getMatches(signer.getSID()).iterator().next();
return name == null || formatName(cert.getSubject(), false).toLowerCase().contains(name.toLowerCase());
}));
if (signatures.size() == signatureCount) {
List<String> criteria = new ArrayList<>();
if (name != null) {
criteria.add("the name '" + name + "'");
}
if (alg != null) {
criteria.add("the digest algorithm " + alg);
}
log.info("No signature matching " + String.join(" and ", criteria) + " found in " + file);
return;
}
int removedCount = signatureCount - signatures.size();
log.info("Removing " + removedCount + " signature" + (removedCount > 1 ? "s" : "") + " from " + file);
signable.setSignatures(signatures);
signable.save();
} catch (UnsupportedOperationException | IllegalArgumentException e) {
throw new CommandException(e.getMessage(), e);
} catch (Exception e) {
throw new CommandException("Couldn't remove the signature from " + file, e);
}
}
}
/**
* Command for displaying the signatures of a file.
*/
public class Show extends Command {
private boolean verbose;
/**
* Prints more information.
*/
public Show verbose(boolean verbose) {
this.verbose = verbose;
return this;
}
void execute(File file) throws CommandException {
if (!file.exists()) {
throw new CommandException("Couldn't find " + file);
}
AnsiFormatter ansiFormatter = new AnsiFormatter();
log.setFilter(record -> {
record.setMessage(ansiFormatter.format(record.getMessage()));
return true;
});
try (Signable signable = Signable.of(file)) {
List<CMSSignedData> signatures = signable.getSignatures();
if (signatures.isEmpty()) {
log.info("No signature found in " + (verbose ? file.getAbsolutePath() : file.getName()));
return;
}
log.info("Signature" + (signatures.size() > 1 ? "s" : "") + " of " + (verbose ? file.getAbsolutePath() : file.getName()) + ":");
log.info("");
DateFormat datetimeFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z");
DateFormat dateFormat = verbose ? datetimeFormat : new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < signatures.size(); i++) {
CMSSignedData signature = signatures.get(i);
SignerInformation signer = signature.getSignerInfos().iterator().next();
DigestInfo digestInfo = SignatureUtils.getDigestInfo(signature);
X509CertificateHolder cert = (X509CertificateHolder) signature.getCertificates().getMatches(signer.getSID()).iterator().next();
boolean expired = cert.getNotAfter().before(new Date());
long daysLeft = Duration.between(Instant.now(), cert.getNotAfter().toInstant()).toDays();
if (signatures.size() > 1) {
log.info("Signature #" + (i + 1));
}
if (digestInfo != null) {
DigestAlgorithm digestAlgorithm = DigestAlgorithm.of(signer.getDigestAlgorithmID().getAlgorithm());
byte[] computedDigest = signable.computeDigest(digestAlgorithm);
boolean matches = Arrays.equals(computedDigest, digestInfo.getDigest());
log.info(" <b>Digest:</b> (" + digestAlgorithm.id + ") " + Hex.toHexString(digestInfo.getDigest()) + (matches ? " (<green>matches</green>)" : " (<red>mismatches</red>)"));
if (!matches) {
log.info(" <b>Expected Digest:</b> (" + digestAlgorithm.id + ") " + Hex.toHexString(computedDigest));
}
}
Date timestamp = SignatureUtils.getTimestampDate(signature);
if (timestamp != null) {
X509CertificateHolder timestampCertificate = SignatureUtils.getTimestampCertificate(signature);
log.info(" <b>Timestamp:</b> " + datetimeFormat.format(timestamp) + " (" + formatName(timestampCertificate.getSubject(), verbose) + ")");
}
SpcSpOpusInfo spOpusInfo = SignatureUtils.getSpcSpOpusInfo(signature);
if (spOpusInfo != null) {
if (spOpusInfo.getProgramName() != null && !spOpusInfo.getProgramName().trim().isEmpty()) {
log.info(" <b>Program Name:</b> " + spOpusInfo.getProgramName());
}
SpcLink moreInfo = spOpusInfo.getMoreInfo();
if (moreInfo != null && moreInfo.getUrl() != null && !moreInfo.getUrl().trim().isEmpty()) {
log.info(" <b>URL:</b> " + moreInfo.getUrl());
}
}
String tag = formatTag(SignatureUtils.getTag(signature));
if (tag != null) {
log.info(" <b>Tag:</b> " + tag.trim());
}
log.info(" <b>Certificate</b>");
log.info(" <b>Subject:</b> " + formatName(cert.getSubject(), verbose));
log.info(" <b>Issuer:</b> " + formatName(cert.getIssuer(), verbose));
log.info(" <b>Key:</b> " + getKeyAlgorithm(new JcaX509CertificateConverter().getCertificate(cert)));
log.info(" <b>Validity:</b> " + dateFormat.format(cert.getNotBefore()) + " - " + dateFormat.format(cert.getNotAfter()) + " (" + (expired ? "expired" : daysLeft + " days left") + ")");
log.info(" <b>Serial:</b> " + String.format("%032x", cert.getSerialNumber()));
log.info("");
}
} catch (Exception e) {
throw new CommandException("Couldn't show the signatures of" + file, e);
}
}
/**
* Formats the value of the unsigned tag.
*/
private String formatTag(ASN1Encodable value) {
if (value != null) {
if (value instanceof ASN1UTF8String) {
return ((ASN1UTF8String) value).getString();
}
if (value instanceof DEROctetString) {
int limit = 100;
byte[] bytes = ((DEROctetString) value).getOctets();
return "(" + bytes.length + " bytes) " + new String(bytes).substring(0, limit) + (bytes.length > limit ? " ... (truncated)" : "");
}
}
return null;
}
}
/**
* Command for verifying the signatures of a file.
*/
public class Verify extends ProxyCommand<Verify> {
private LocalDate date;
private File certfile;
private boolean lazy;
private Charset encoding;
private boolean verbose;
/**
* The date at which the signature is verified (overridden by a timestamp)
*/
public Verify date(LocalDate date) {
this.date = date;
return this;
}
/**
* Sets the file containing trusted CA certificates.
*/
public Verify certfile(String certfile) {
this.certfile = createFile(certfile);
return this;
}
/**
* Sets the file containing trusted CA certificates.
*/
public Verify certfile(File certfile) {
this.certfile = certfile;
return this;
}
/**
* Lazy verification stopping at the first issue found for each signature.
*/
public Verify lazy(boolean lazy) {
this.lazy = lazy;
return this;
}
/**
* The encoding of the script to be verified (UTF-8 by default, or the encoding specified by the byte order mark if there is one)
*/
public Verify encoding(String encoding) {
this.encoding = encoding != null ? Charset.forName(encoding) : null;
return this;
}
/**
* Print a detailed report of the verification.
*/
public Verify verbose(boolean verbose) {
this.verbose = verbose;
return this;
}
@Override
void execute(File file) throws CommandException {
init();
execute(file, false);
}
@Override
public void execute(Stream<File> files) throws CommandException {
Iterator<File> it = files.iterator();
if (!it.hasNext()) {
throw new IllegalArgumentException("No file specified");
}
init();
while (it.hasNext()) {
execute(it.next(), true);
if (it.hasNext()) {
log.info("");
}
}
}
void init() throws CommandException {
initializeProxy();
AnsiFormatter ansiFormatter = new AnsiFormatter();
log.setFilter(record -> {
record.setMessage(ansiFormatter.format(record.getMessage()));
return true;
});
}
void execute(File file, boolean batch) throws CommandException {
if (!file.exists()) {
throw new CommandException("Couldn't find " + file);
}
SignatureVerifier verifier = new SignatureVerifier();
verifier.setDate(date != null ? Date.from(date.atStartOfDay(ZoneId.of("UTC")).toInstant()) : null);
verifier.setLazy(lazy);
if (certfile != null) {
try {
for (X509Certificate certificate : CertificateChain.load(certfile)) {
verifier.addTrustedCertificate(certificate);
}
} catch (IOException | CertificateException e) {
throw new CommandException("Failed to load the trusted certificates from " + certfile, e);
}
}
try (Signable signable = Signable.of(file, encoding)) {
VerificationResult result = verifier.verify(signable);
if (result.getSignatureVerifications().isEmpty()) {
log.info(file + " is <red>not signed</red>");
throw new VerificationException(2, file + " is not signed");
}
if (batch) {
log.info("Verifying " + file + "...");
if (verbose) {
log.info("");
}
}
List<VerificationResult.SignatureVerification> signatureVerifications = result.getSignatureVerifications();
for (int i = 0; i < signatureVerifications.size(); i++) {
VerificationResult.SignatureVerification signatureVerification = signatureVerifications.get(i);
String commonName = formatName(new X500Name(signatureVerification.getCertificate().getSubjectX500Principal().getName()), false);
StringBuilder message = new StringBuilder();
message.append("<b>Signature #").append(i + 1).append("</b>");
message.append(" (").append(signatureVerification.getDigestAlgorithm()).append(" with ").append(getKeyAlgorithm(signatureVerification.getCertificate())).append(")");
message.append(" by <b>").append(commonName).append("</b>");
if (signatureVerification.isValid()) {
message.append(" is <green><b>valid</b></green>");
} else {
message.append(" is <red><b>invalid</b></red>");
if (!verbose) {
message.append(" (").append(signatureVerification.getIssues().get(0).getMessage()).append(")");
}
}
log.info(message.toString());
if (verbose) {
for (CheckResult check : signatureVerification.getChecks()) {
message = new StringBuilder();
message.append(" ");
switch (check.getStatus()) {
case PASSED:
message.append("<green><b>PASS</b></green>");
break;
case FAILED:
message.append("<red><b>FAIL</b></red>");
break;
case SKIPPED:
message.append("<yellow><b>SKIP</b></yellow>");
break;
}
message.append(" ");
message.append(String.format("%-32s", "<b>" + check.getRule() + ":</b> "));
message.append(check.getMessage());
log.info(message.toString());
String indent = " ";
if (check.getExpected() != null) {
log.info(indent + "expected: " + check.getExpected());
}
if (check.getActual() != null) {
log.info(indent + "actual: " + check.getActual());
}
if (check.getError() != null && check.getError().getMessage() != null) {
Throwable rootCause = check.getError();
while (rootCause.getCause() != null) {
rootCause = rootCause.getCause();
}
log.info(indent + rootCause.getMessage().replaceAll("\n", "\n" + indent).trim());
}
}
log.info("");
}
}
if (!result.isValid()) {
throw new VerificationException(4, "Signature verification failed for " + file);
}
} catch (IOException e){
throw new CommandException("Couldn't verify the signature of " + file, e);
}
}
}
private static File getDetachedSignature(File file) {
return new File(file.getParentFile(), file.getName() + ".sig");
}
private static void attach(Signable signable, File detachedSignature) throws IOException {
byte[] signatureBytes = Files.readAllBytes(detachedSignature.toPath());
signable.setSignatures(SignatureUtils.getSignatures(signatureBytes));
signable.save();
// todo warn if the hashes don't match
}
private static void detach(Signable signable, File detachedSignature, String format) throws IOException {
List<CMSSignedData> signatures = signable.getSignatures();
// ensure the secondary signatures are nested in the first one (for EFI files)
CMSSignedData signedData = signatures.get(0);
if (signatures.size() > 1) {
List<CMSSignedData> nestedSignatures = signatures.subList(1, signatures.size());
signedData = SignatureUtils.addNestedSignature(signedData, true, nestedSignatures.toArray(new CMSSignedData[0]));
}
byte[] content = signedData.toASN1Structure().getEncoded("DER");
if (format == null || "DER".equalsIgnoreCase(format)) {
Files.write(detachedSignature.toPath(), 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 + "'");
}
}
/**
* Formats the X500 name:
* <ul>
* <li>in normal mode, returns only the common name (CN)</li>
* <li>in verbose mode, returns the full name in LDAP order (starting with the common name)</li>
* </ul>
*/
private static String formatName(X500Name name, boolean verbose) {
if (verbose) {
return new X500Name(new BCStyle() {
public String toString(X500Name name) {
StringBuilder buf = new StringBuilder();
RDN[] rdns = name.getRDNs();
for (int i = rdns.length - 1; i >= 0; i--) {
if (i != rdns.length - 1) {
buf.append(", ");
}
IETFUtils.appendRDN(buf, rdns[i], defaultSymbols);
}
return buf.toString();
}
}, name.getRDNs()).toString().replaceAll("\\\\,", ",");
} else {
return name.getRDNs(BCStyle.CN)[0].getFirst().getValue().toString();
}
}
/**
* Returns the algorithm of the public key of the certificate (for example "RSA 2048" or "EC 384").
*/
private String getKeyAlgorithm(X509Certificate certificate) throws IOException {
PublicKey publicKey = certificate.getPublicKey();
if (publicKey instanceof RSAPublicKey) {
return "RSA " + ((RSAPublicKey) publicKey).getModulus().bitLength();
} else if (publicKey instanceof ECPublicKey) {
return "EC " + (((ECPublicKey) publicKey).getParams()).getCurve().getField().getFieldSize();
} else {
return publicKey.getAlgorithm();
}
}
}