Skip to content

Commit

Permalink
Automatic download of the intermediate certificates (Fixes #217)
Browse files Browse the repository at this point in the history
  • Loading branch information
ebourg committed May 10, 2024
1 parent 113d76d commit 3bc43b3
Show file tree
Hide file tree
Showing 10 changed files with 414 additions and 40 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Jsign is free to use and licensed under the [Apache License version 2.0](https:/
* [SSL.com eSigner](https://www.ssl.com/esigner/)
* Private key formats: PVK and PEM (PKCS#1 and PKCS#8), encrypted or not
* Certificates: PKCS#7 in PEM and DER format
* Automatic download of the intermediate certificates
* Build tools integration (Maven, Gradle, Ant, GitHub Actions)
* Command line signing tool
* Authenticode signing API ([Javadoc](https://javadoc.io/doc/net.jsign/jsign-core))
Expand All @@ -55,6 +56,7 @@ See https://ebourg.github.io/jsign for more information.
* The Azure Trusted Signing service has been integrated
* The Oracle Cloud signing service has been integrated
* Signing of NuGet packages has been implemented (contributed by Sebastian Stamm)
* The intermediate certificates are downloaded if missing from the keystore or the certificate chain file
* Jsign now checks if the certificate subject matches the app manifest publisher before signing APPX/MSIX packages
* The `extract` command has been added to extract the signature from a signed file, in DER or PEM format
* The `remove` command has been added to remove the signature from a signed file
Expand Down
25 changes: 14 additions & 11 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ <h3 id="features">Features</h3>
<li>Timestamping with retries and fallback on alternative servers (RFC 3161 and Authenticode protocols supported)</li>
<li>Supports multiple signatures per file, for all file types</li>
<li>Extracts and embeds detached signatures to support <a href="https://reproducible-builds.org/docs/embedded-signatures/">reproducible builds</a></li>
<li>Certificate chain completion</li>
<li>Hashing algorithms: MD5, SHA-1, SHA-256, SHA-384 and SHA-512</li>
<li>Keystores supported:
<ul>
Expand All @@ -81,6 +82,7 @@ <h3 id="features">Features</h3>
</ul>
<li>Private key formats: PVK and PEM (PKCS#1 and PKCS#8), encrypted or not</li>
<li>Certificates: PKCS#7 in PEM and DER format</li>
<li>Automatic download of the intermediate certificates</li>
<li>Build tools integration (<a href="#maven">Maven</a>, <a href="#gradle">Gradle</a>, <a href="#ant">Ant</a>, <a href="#github-actions">GitHub Actions</a>)</li>
<li>Command line signing tool</li>
<li>Authenticode signing API (<a href="https://javadoc.io/doc/net.jsign/jsign-core">Javadoc</a>)</li>
Expand Down Expand Up @@ -211,8 +213,14 @@ <h4 id="attributes" class="mobile-only">Attributes</h4>
</tr>
<tr>
<td class="attribute">certfile</td>
<td class="description">The file containing the PKCS#7 certificate chain (<code>.p7b</code> or <code>.spc</code> files).</td>
<td class="required" rowspan="2">Yes, unless <code>keystore</code> is specified.</td>
<td class="description">
The file containing the PKCS#7 certificate chain, in PEM or DER format (<code>.p7b</code> or <code>.spc</code>
files). This parameter is required if <code>keystore</code> isn't specified, or if the keystore used contains
only the key and not the certificate chain. It may be specified if the keystore holds only the signing
certificate and not the full certificate chain, otherwise Jsign will attempt to download the missing
certificates from the URL specified in the Authority Information Access field of the certificates.
</td>
<td class="required" rowspan="2">No, depends on the keystore used.</td>
</tr>
<tr>
<td class="attribute">keyfile</td>
Expand Down Expand Up @@ -551,7 +559,7 @@ <h4 id="example-yubikey">Signing with a YubiKey</h4>
be installed on the system at the default location.</p>

<pre>
jsign --storetype YUBIKEY --storepass 123456 --certfile full-chain.pem application.exe
jsign --storetype YUBIKEY --storepass 123456 application.exe
</pre>

<p>Alternatively, the PIV storetype can also be used to sign with a Yubikey and doesn't require the ykcs11 library.</p>
Expand All @@ -576,7 +584,7 @@ <h4 id="example-etoken">Signing with a SafeNet eToken</h4>
<a href="https://knowledge.digicert.com/general-information/how-to-download-safenet-authentication-client">SafeNet Authentication Client</a>.</p>

<pre>
jsign --storetype ETOKEN --storepass &ltPIN&gt; --certfile full-chain.pem application.exe
jsign --storetype ETOKEN --storepass &ltPIN&gt; application.exe
</pre>

<h4 id="example-smart-card">Signing with a smart card</h4>
Expand All @@ -603,8 +611,7 @@ <h4 id="example-openpgp">Signing with an OpenPGP card</h4>
--certfile full-chain.pem application.exe
</pre>

<p>X.509 certificates stored on the card are automatically used, and the <code>certfile</code> parameter is only
required if the certificate chain contains an intermediate certificate.</p>
<p>The <code>certfile</code> parameter is only required if no X.509 certificate is stored on the card for the key used.</p>

<p>If multiple devices are connected, the <code>keystore</code> parameter can be used to specify
the name of the one to use.</p>
Expand All @@ -616,13 +623,9 @@ <h4 id="example-piv">Signing with a PIV card</h4>
Slot numbers are also accepted (for example <code>9c</code> for the digital signature key).

<pre>
jsign --storetype PIV --storepass 123456 --alias SIGNATURE \
--certfile full-chain.pem application.exe
jsign --storetype PIV --storepass 123456 --alias SIGNATURE application.exe
</pre>

<p>X.509 certificates stored on the card are automatically used, and the <code>certfile</code> parameter is only
required if the certificate chain contains an intermediate certificate.</p>

<p>If multiple devices are connected, the <code>keystore</code> parameter can be used to specify
the name of the one to use.</p>

Expand Down
31 changes: 4 additions & 27 deletions jsign-core/src/main/java/net/jsign/AuthenticodeSigner.java
Original file line number Diff line number Diff line change
Expand Up @@ -423,9 +423,12 @@ protected CMSSignedData createSignedData(Signable file) throws Exception {
}

private CMSSignedDataGenerator createSignedDataGenerator(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(removeRoot(chain)));
generator.addCertificates(new JcaCertStore(fullChain));
generator.addSignerInfoGenerator(createSignerInfoGenerator());

return generator;
Expand Down Expand Up @@ -467,32 +470,6 @@ public AlgorithmIdentifier findEncryptionAlgorithm(final AlgorithmIdentifier sig
return signerInfoGeneratorBuilder.build(shaSigner, certificate);
}

/**
* Remove the root certificate from the chain, unless the chain consists in a single self signed certificate.
*
* @param certificates the certificate chain to process
* @return the certificate chain without the root certificate
*/
private List<Certificate> removeRoot(Certificate[] certificates) {
List<Certificate> list = new ArrayList<>();

if (certificates.length == 1) {
list.add(certificates[0]);
} else {
for (Certificate certificate : certificates) {
if (!isSelfSigned((X509Certificate) certificate)) {
list.add(certificate);
}
}
}

return list;
}

private boolean isSelfSigned(X509Certificate certificate) {
return certificate.getSubjectDN().equals(certificate.getIssuerDN());
}

private void verify(CMSSignedData signedData) throws SignatureException, OperatorCreationException {
X509Certificate certificate = (X509Certificate) chain[0];
PublicKey publicKey = certificate.getPublicKey();
Expand Down
121 changes: 121 additions & 0 deletions jsign-core/src/main/java/net/jsign/CertificateUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,29 @@
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import javax.security.auth.x500.X500Principal;

import org.bouncycastle.asn1.ASN1OctetString;
import org.bouncycastle.asn1.x509.AccessDescription;
import org.bouncycastle.asn1.x509.AuthorityInformationAccess;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.X509ObjectIdentifiers;

/**
* @since 5.0
*/
Expand Down Expand Up @@ -59,4 +72,112 @@ public static Comparator<X509Certificate> getChainComparator() {
.thenComparing(X509Certificate::getNotBefore, Comparator.reverseOrder())
.thenComparing(X509Certificate::getSubjectX500Principal, Comparator.comparing(X500Principal::getName));
}

/**
* Returns the authority information access extension of the specified certificate.
*
* @since 6.1
*/
public static AuthorityInformationAccess getAuthorityInformationAccess(X509Certificate certificate) {
byte[] aia = certificate.getExtensionValue(Extension.authorityInfoAccess.getId());
return aia != null ? AuthorityInformationAccess.getInstance(ASN1OctetString.getInstance(aia).getOctets()) : null;
}

/**
* Returns the issuer certificate URL of the specified certificate.
*
* @since 6.1
*/
public static String getIssuerCertificateURL(X509Certificate certificate) {
AuthorityInformationAccess aia = getAuthorityInformationAccess(certificate);
if (aia != null) {
for (AccessDescription access : aia.getAccessDescriptions()) {
if (X509ObjectIdentifiers.id_ad_caIssuers.equals(access.getAccessMethod())) {
return access.getAccessLocation().getName().toString();
}
}
}

return null;
}

/**
* Returns the issuer certificates of the specified certificate. Multiple issuer certificates may be returned
* if the certificate is cross-signed.
*
* @since 6.1
*/
public static Collection<X509Certificate> getIssuerCertificates(X509Certificate certificate) throws IOException, CertificateException {
String certificateURL = getIssuerCertificateURL(certificate);
if (certificateURL != null) {
File cacheDirectory = new File(OSUtils.getCacheDirectory("jsign"), "certificates");
HttpClient cache = new HttpClient(cacheDirectory, 90 * 24 * 3600 * 1000L);
try (InputStream in = cache.getInputStream(new URL(certificateURL))) {
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
return (Collection) certificateFactory.generateCertificates(in);
}
}

return Collections.emptyList();
}

/**
* Returns the certificate chain of the specified certificate up to the specified depth.
*
* @since 6.1
*/
public static Collection<X509Certificate> getCertificateChain(X509Certificate certificate, int maxDepth) {
List<X509Certificate> chain = new ArrayList<>();
chain.add(certificate);

if (maxDepth > 0 && !isSelfSigned(certificate)) {
try {
Collection<X509Certificate> issuers = getIssuerCertificates(certificate);
for (X509Certificate issuer : issuers) {
chain.addAll(getCertificateChain(issuer, maxDepth - 1));
}
} catch (Exception e) {
e.printStackTrace();
}
}

return chain;
}

/**
* Tells if the specified certificate is self-signed.
*
* @since 6.1
*/
public static boolean isSelfSigned(X509Certificate certificate) {
return certificate.getSubjectDN().equals(certificate.getIssuerDN());
}

/**
* Completes the specified chain with the missing issuer certificates.
*
* @since 6.1
*/
public static List<X509Certificate> getFullCertificateChain(Collection<X509Certificate> chain) {
Set<String> issuerNames = chain.stream().map(c -> c.getIssuerX500Principal().getName()).collect(Collectors.toSet());

Set<String> missingIssuerNames = new LinkedHashSet<>(issuerNames);
for (X509Certificate certificate : chain) {
missingIssuerNames.remove(certificate.getSubjectX500Principal().getName());
}
Set<X509Certificate> orphanCertificates = new HashSet<>();
for (X509Certificate certificate : chain) {
if (missingIssuerNames.contains(certificate.getIssuerX500Principal().getName())) {
orphanCertificates.add(certificate);
}
}

List<X509Certificate> fullChain = new ArrayList<>(chain);
for (X509Certificate orphanCertificate : orphanCertificates) {
fullChain.remove(orphanCertificate);
fullChain.addAll(getCertificateChain(orphanCertificate, 10));
}

return fullChain;
}
}
107 changes: 107 additions & 0 deletions jsign-core/src/main/java/net/jsign/HttpClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Copyright 2024 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.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.file.Files;
import java.security.MessageDigest;

import org.apache.commons.codec.binary.Hex;
import org.apache.commons.io.IOUtils;

/**
* Simple HTTP client with resource caching.
*
* @since 6.1
*/
class HttpClient {

/** The directory where the cached files are stored */
private final File cacheDir;

/** The expiration time for the cached files */
private final long expirationTime;

public HttpClient(File cacheDir, long expirationTime) {
this.cacheDir = cacheDir;
this.expirationTime = expirationTime;
}

public InputStream getInputStream(URL url) throws IOException {
File cacheFile = new File(cacheDir, getRequestHash(url) + ".cache");
if (cacheFile.exists() && (System.currentTimeMillis() - cacheFile.lastModified()) < expirationTime) {
return new FileInputStream(cacheFile);
} else {
HttpURLConnection conn = connect(url);
if (conn.getResponseCode() >= 400) {
throw new IOException("Unable to read " + url + " : " + conn.getResponseCode() + " - " + conn.getResponseMessage());
}

InputStream in = conn.getInputStream();
byte[] response = IOUtils.toByteArray(in);
in.close();

conn.disconnect();

// put the response in the cache
cacheFile.getParentFile().mkdirs();
Files.write(cacheFile.toPath(), response);

return new ByteArrayInputStream(response);
}
}

/**
* Connect to the specified URL and follow the redirections (including http -> https).
*/
private HttpURLConnection connect(URL url) throws IOException {
int redirections = 0;

while (redirections++ < 10) {
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
String userAgent = System.getProperty("http.agent");
conn.setRequestProperty("User-Agent", "Jsign (https://ebourg.github.io/jsign/)" + (userAgent != null ? " " + userAgent : ""));
conn.setConnectTimeout(15000);
conn.setReadTimeout(15000);
conn.setInstanceFollowRedirects(false);

switch (conn.getResponseCode()) {
case HttpURLConnection.HTTP_MOVED_PERM:
case HttpURLConnection.HTTP_MOVED_TEMP:
url = new URL(url, conn.getHeaderField("Location"));
continue;
}

return conn;
}

throw new IOException("Too many redirections for " + url);
}

String getRequestHash(URL url) {
MessageDigest digest = DigestAlgorithm.SHA1.getMessageDigest();
digest.update(url.toString().getBytes());

return Hex.encodeHexString(digest.digest());
}
}

0 comments on commit 3bc43b3

Please sign in to comment.