CertInformationCollector.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.pdfbox.examples.signature.validation;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;

import java.net.URISyntaxException;

import java.security.GeneralSecurityException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;

import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;

import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.LogManager;
import org.apache.pdfbox.examples.signature.SigUtils;
import org.apache.pdfbox.examples.signature.cert.CertificateVerifier;
import org.apache.pdfbox.pdmodel.encryption.SecurityProvider;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;

import org.bouncycastle.asn1.ASN1Encodable;
import org.bouncycastle.asn1.ASN1Object;
import org.bouncycastle.asn1.cms.Attribute;
import org.bouncycastle.asn1.cms.AttributeTable;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.asn1.x509.Extension;
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.SignerInformation;
import org.bouncycastle.util.Store;

/**
 * This class helps to extract data/information from a signature. The information is held in
 * CertSignatureInformation. Some information is needed for validation processing of the
 * participating certificates.
 *
 * @author Alexis Suter
 *
 */
public class CertInformationCollector
{
    private static final Logger LOG = LogManager.getLogger(CertInformationCollector.class);

    private static final int MAX_CERTIFICATE_CHAIN_DEPTH = 5;

    private final Set<X509Certificate> certificateSet = new HashSet<>();
    private final Set<String> urlSet = new HashSet<>();

    private final JcaX509CertificateConverter certConverter = new JcaX509CertificateConverter();

    private CertSignatureInformation rootCertInfo;

    /**
     * Gets the certificate information of a signature.
     * 
     * @param signature the signature of the document.
     * @param fileName of the document.
     * @return the CertSignatureInformation containing all certificate information
     * @throws CertificateProccessingException when there is an error processing the certificates
     * @throws IOException on a data processing error
     */
    public CertSignatureInformation getLastCertInfo(PDSignature signature, String fileName)
            throws CertificateProccessingException, IOException
    {
        try (FileInputStream documentInput = new FileInputStream(fileName))
        {
            byte[] signatureContent = signature.getContents(documentInput);
            return getCertInfo(signatureContent);
        }
    }

    /**
     * Processes one signature and its including certificates.
     *
     * @param signatureContent the byte[]-Content of the signature
     * @return the CertSignatureInformation for this signature
     * @throws IOException
     * @throws CertificateProccessingException
     */
    private CertSignatureInformation getCertInfo(byte[] signatureContent)
            throws CertificateProccessingException, IOException
    {
        rootCertInfo = new CertSignatureInformation();

        // https://www.etsi.org/deliver/etsi_ts/102700_102799/10277804/01.01.02_60/ts_10277804v010102p.pdf
        // The key of each entry in this dictionary is the base-16-encoded (uppercase)
        // SHA1 digest of the signature to which it applies
        rootCertInfo.signatureHash = CertInformationHelper.getSha1Hash(signatureContent);

        try
        {
            CMSSignedData signedData = new CMSSignedData(signatureContent);
            SignerInformation signerInformation = processSignerStore(signedData, rootCertInfo);
            addTimestampCerts(signerInformation);
        }
        catch (CMSException e)
        {
            LOG.error("Error occurred getting Certificate Information from Signature", e);
            throw new CertificateProccessingException(e);
        }
        return rootCertInfo;
    }

    /**
     * Processes an embedded signed timestamp, that has been placed into a signature. The
     * certificates and its chain(s) will be processed the same way as the signature itself.
     *
     * @param signerInformation of the signature, to get unsigned attributes from it.
     * @throws IOException
     * @throws CertificateProccessingException
     */
    private void addTimestampCerts(SignerInformation signerInformation)
            throws IOException, CertificateProccessingException
    {
        AttributeTable unsignedAttributes = signerInformation.getUnsignedAttributes();
        if (unsignedAttributes == null)
        {
            return;
        }
        Attribute tsAttribute = unsignedAttributes
                .get(PKCSObjectIdentifiers.id_aa_signatureTimeStampToken);
        if (tsAttribute == null)
        {
            return;
        }
        ASN1Encodable obj0 = tsAttribute.getAttrValues().getObjectAt(0);
        if (!(obj0 instanceof ASN1Object))
        {
            return;
        }
        ASN1Object tsSeq = (ASN1Object) obj0;

        try
        {
            CMSSignedData signedData = new CMSSignedData(tsSeq.getEncoded("DER"));
            rootCertInfo.tsaCerts = new CertSignatureInformation();
            processSignerStore(signedData, rootCertInfo.tsaCerts);
        }
        catch (CMSException e)
        {
            throw new IOException("Error parsing timestamp token", e);
        }
    }

    /**
     * Processes a signer store and goes through the signers certificate-chain. Adds the found data
     * to the certInfo. Handles only the first signer, although multiple would be possible, but is
     * not yet practicable.
     *
     * @param signedData data from which to get the SignerInformation
     * @param certInfo where to add certificate information
     * @return Signer Information of the processed certificatesStore for further usage.
     * @throws IOException on data-processing error
     * @throws CertificateProccessingException on a specific error with a certificate
     */
    private SignerInformation processSignerStore(
            CMSSignedData signedData, CertSignatureInformation certInfo)
            throws IOException, CertificateProccessingException
    {
        Collection<SignerInformation> signers = signedData.getSignerInfos().getSigners();
        SignerInformation signerInformation = signers.iterator().next();

        Store<X509CertificateHolder> certificatesStore = signedData.getCertificates();
        @SuppressWarnings("unchecked")
        Collection<X509CertificateHolder> matches =
                certificatesStore.getMatches(signerInformation.getSID());

        X509Certificate certificate = getCertFromHolder(matches.iterator().next());
        certificateSet.add(certificate);

        Collection<X509CertificateHolder> allCerts = certificatesStore.getMatches(null);
        addAllCerts(allCerts);
        traverseChain(certificate, certInfo, MAX_CERTIFICATE_CHAIN_DEPTH);
        return signerInformation;
    }

    /**
     * Traverse through the Cert-Chain of the given Certificate and add it to the CertInfo
     * recursively.
     *
     * @param certificate Actual Certificate to be processed
     * @param certInfo where to add the Certificate (and chain) information
     * @param maxDepth Max depth from this point to go through CertChain (could be infinite)
     * @throws IOException on data-processing error
     * @throws CertificateProccessingException on a specific error with a certificate
     */
    private void traverseChain(X509Certificate certificate, CertSignatureInformation certInfo,
            int maxDepth) throws IOException, CertificateProccessingException
    {
        certInfo.certificate = certificate;

        // Certificate Authority Information Access
        // As described in https://tools.ietf.org/html/rfc3280.html#section-4.2.2.1
        byte[] authorityExtensionValue = certificate.getExtensionValue(Extension.authorityInfoAccess.getId());
        if (authorityExtensionValue != null)
        {
            CertInformationHelper.getAuthorityInfoExtensionValue(authorityExtensionValue, certInfo);
        }

        if (certInfo.issuerUrl != null)
        {
            getAlternativeIssuerCertificate(certInfo, maxDepth);
        }

        // As described in https://tools.ietf.org/html/rfc3280.html#section-4.2.1.14
        byte[] crlExtensionValue = certificate.getExtensionValue(Extension.cRLDistributionPoints.getId());
        if (crlExtensionValue != null)
        {
            certInfo.crlUrl = CertInformationHelper.getCrlUrlFromExtensionValue(crlExtensionValue);
        }

        certInfo.isSelfSigned = CertificateVerifier.isSelfSigned(certificate);
        if (maxDepth <= 0 || certInfo.isSelfSigned)
        {
            return;
        }

        int count = 0;
        for (X509Certificate issuer : certificateSet)
        {
            try
            {
                certificate.verify(issuer.getPublicKey(), SecurityProvider.getProvider());
                LOG.info("Found issuer for Cert: {}\n{}",
                        certificate.getSubjectX500Principal(), issuer.getSubjectX500Principal());
                certInfo.issuerCertificates.add(issuer);
                certInfo.certChain = new CertSignatureInformation();
                traverseChain(issuer, certInfo.certChain, maxDepth - 1);
                ++count;
            }
            catch (GeneralSecurityException ex)
            {
                // not the issuer
            }                
        }
        if (certInfo.issuerCertificates.isEmpty())
        {
            throw new IOException(
                    "No Issuer Certificate found for Cert: '" +
                            certificate.getSubjectX500Principal() + "', i.e. Cert '" +
                            certificate.getIssuerX500Principal() + "' is missing in the chain");
        }
        if (count > 1)
        {
            // not a bug, see comment by mkl in PDFBOX-5203
            LOG.info("Several issuers for Cert: '{}", certificate.getSubjectX500Principal());
        }
    }

    /**
     * Get alternative certificate chain, from the Authority Information (a url). If the chain is
     * not included in the signature, this is the main chain. Otherwise there might be a second
     * chain. Exceptions which happen on this chain will be logged and ignored, because the cert
     * might not be available at the time or other reasons.
     *
     * @param certInfo base Certificate Information, on which to put the alternative Certificate
     * @param maxDepth Maximum depth to dig through the chain from here on.
     * @throws CertificateProccessingException on a specific error with a certificate
     */
    private void getAlternativeIssuerCertificate(CertSignatureInformation certInfo, int maxDepth)
            throws CertificateProccessingException
    {
        if (urlSet.contains(certInfo.issuerUrl))
        {
            return;
        }
        urlSet.add(certInfo.issuerUrl);
        LOG.info("Get alternative issuer certificate from: {}", certInfo.issuerUrl);
        try
        {
            CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
            try (InputStream in = SigUtils.openURL(certInfo.issuerUrl))
            {
                X509Certificate altIssuerCert = (X509Certificate) certFactory
                        .generateCertificate(in);
                certificateSet.add(altIssuerCert);

                certInfo.alternativeCertChain = new CertSignatureInformation();
                traverseChain(altIssuerCert, certInfo.alternativeCertChain, maxDepth - 1);
            }
        }
        catch (IOException | URISyntaxException | CertificateException e)
        {
            LOG.error(() -> "Error getting alternative issuer certificate from " + certInfo.issuerUrl,
                    e);
        }
    }

    /**
     * Gets the X509Certificate out of the X509CertificateHolder.
     *
     * @param certificateHolder to get the certificate from
     * @return a X509Certificate or <code>null</code> when there was an Error with the Certificate
     * @throws CertificateProccessingException on failed conversion from X509CertificateHolder to
     * X509Certificate
     */
    private X509Certificate getCertFromHolder(X509CertificateHolder certificateHolder)
            throws CertificateProccessingException
    {
        try
        {
            return certConverter.getCertificate(certificateHolder);
        }
        catch (CertificateException e)
        {
            LOG.error("Certificate Exception getting Certificate from certHolder.", e);
            throw new CertificateProccessingException(e);
        }
    }

    /**
     * Adds multiple Certificates out of a Collection of X509CertificateHolder into certificateSet.
     *
     * @param certHolders Collection of X509CertificateHolder
     */
    private void addAllCerts(Collection<X509CertificateHolder> certHolders)
    {
        for (X509CertificateHolder certificateHolder : certHolders)
        {
            try
            {
                X509Certificate certificate = getCertFromHolder(certificateHolder);
                certificateSet.add(certificate);
            }
            catch (CertificateProccessingException e)
            {
                LOG.warn("Certificate Exception getting Certificate from certHolder.", e);
            }
        }
    }

    /**
     * Gets a list of X509Certificate out of an array of X509CertificateHolder. The certificates
     * will be added to certificateSet.
     *
     * @param certHolders Array of X509CertificateHolder
     * @throws CertificateProccessingException when one of the Certificates could not be parsed.
     */
    public void addAllCertsFromHolders(X509CertificateHolder[] certHolders)
            throws CertificateProccessingException
    {
        addAllCerts(Arrays.asList(certHolders));
    }

    /**
     * Traverse a certificate.
     *
     * @param certificate
     * @return
     * @throws CertificateProccessingException 
     */
    CertSignatureInformation getCertInfo(X509Certificate certificate) throws CertificateProccessingException
    {
        try
        {
            CertSignatureInformation certSignatureInformation = new CertSignatureInformation();
            traverseChain(certificate, certSignatureInformation, MAX_CERTIFICATE_CHAIN_DEPTH);
            return certSignatureInformation;
        }
        catch (IOException ex)
        {
            throw new CertificateProccessingException(ex);
        }
    }

    /**
     * Get the set of all processed certificates until now.
     * 
     * @return a set of serial numbers to certificates.
     */
    public Set<X509Certificate> getCertificateSet()
    {
        return certificateSet;
    }

    /**
     * Data class to hold Signature, Certificate (and its chain(s)) and revocation Information
     */
    public static class CertSignatureInformation
    {
        private X509Certificate certificate;
        private String signatureHash;
        private boolean isSelfSigned = false;
        private String ocspUrl;
        private String crlUrl;
        private String issuerUrl;
        private final Set<X509Certificate> issuerCertificates = new HashSet<>();
        private CertSignatureInformation certChain;
        private CertSignatureInformation tsaCerts;
        private CertSignatureInformation alternativeCertChain;

        public String getOcspUrl()
        {
            return ocspUrl;
        }

        public void setOcspUrl(String ocspUrl)
        {
            this.ocspUrl = ocspUrl;
        }

        public void setIssuerUrl(String issuerUrl)
        {
            this.issuerUrl = issuerUrl;
        }

        public String getCrlUrl()
        {
            return crlUrl;
        }

        public X509Certificate getCertificate()
        {
            return certificate;
        }

        public boolean isSelfSigned()
        {
            return isSelfSigned;
        }

        public Set<X509Certificate> getIssuerCertificates()
        {
            return issuerCertificates;
        }

        public String getSignatureHash()
        {
            return signatureHash;
        }

        public CertSignatureInformation getCertChain()
        {
            return certChain;
        }

        public CertSignatureInformation getTsaCerts()
        {
            return tsaCerts;
        }

        public CertSignatureInformation getAlternativeCertChain()
        {
            return alternativeCertChain;
        }
    }
}