AddValidationInformation.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.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URISyntaxException;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.Security;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509CRL;
import java.security.cert.X509Certificate;
import java.util.Calendar;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.LogManager;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSStream;
import org.apache.pdfbox.cos.COSUpdateInfo;
import org.apache.pdfbox.examples.signature.SigUtils;
import org.apache.pdfbox.examples.signature.cert.CRLVerifier;
import org.apache.pdfbox.examples.signature.cert.CertificateVerificationException;
import org.apache.pdfbox.examples.signature.cert.OcspHelper;
import org.apache.pdfbox.examples.signature.cert.RevokedCertificateException;
import org.apache.pdfbox.examples.signature.validation.CertInformationCollector.CertSignatureInformation;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.encryption.SecurityProvider;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.util.Hex;
import org.bouncycastle.asn1.BEROctetString;
import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
import org.bouncycastle.cert.ocsp.BasicOCSPResp;
import org.bouncycastle.cert.ocsp.OCSPException;
import org.bouncycastle.cert.ocsp.OCSPResp;
import org.bouncycastle.cms.CMSException;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.tsp.TSPException;
import org.bouncycastle.tsp.TimeStampToken;
import org.bouncycastle.tsp.TimeStampTokenInfo;

/**
 * An example for adding Validation Information to a signed PDF, inspired by ETSI TS 102 778-4
 * V1.1.2 (2009-12), Part 4: PAdES Long Term - PAdES-LTV Profile. This procedure appends the
 * Validation Information of the last signature (more precise its signer(s)) to a copy of the
 * document. The signature and the signed data will not be touched and stay valid.
 * <p>
 * See also <a href="http://eprints.hsr.ch/id/eprint/616">Bachelor thesis (in German) about LTV</a>
 *
 * @author Alexis Suter
 */
public class AddValidationInformation
{
    private static final Logger LOG = LogManager.getLogger(AddValidationInformation.class);

    private CertInformationCollector certInformationHelper;
    private COSArray correspondingOCSPs;
    private COSArray correspondingCRLs;
    private COSDictionary vriBase;
    private COSArray ocsps;
    private COSArray crls;
    private COSArray certs;
    private final Map<X509Certificate,COSStream> certMap = new HashMap<>();
    private PDDocument document;
    private final Set<X509Certificate> foundRevocationInformation = new HashSet<>();
    private Calendar signDate;
    private final Set<X509Certificate> ocspChecked = new HashSet<>();
    //TODO foundRevocationInformation and ocspChecked have a similar purpose. One of them should likely
    // be removed and the code improved. When doing so, keep in mind that ocspChecked was added last,
    // because of a problem with freetsa.

    /**
     * Signs the given PDF file.
     * 
     * @param inFile input PDF file
     * @param outFile output PDF file
     * @throws IOException if the input file could not be read
     */
    public void validateSignature(File inFile, File outFile) throws IOException
    {
        if (inFile == null || !inFile.exists())
        {
            String err = "Document for signing ";
            if (null == inFile)
            {
                err += "is null";
            }
            else
            {
                err += "does not exist: " + inFile.getAbsolutePath();
            }
            throw new FileNotFoundException(err);
        }

        try (PDDocument doc = Loader.loadPDF(inFile);
             FileOutputStream fos = new FileOutputStream(outFile))
        {
            int accessPermissions = SigUtils.getMDPPermission(doc);
            if (accessPermissions == 1)
            {
                System.out.println("PDF is certified to forbid changes, "
                        + "some readers may report the document as invalid despite that "
                        + "the PDF specification allows DSS additions");
            }
            document = doc;
            doValidation(inFile.getAbsolutePath(), fos);
        }
    }

    /**
     * Fetches certificate information from the last signature of the document and appends a DSS
     * with the validation information to the document.
     *
     * @param filename in file to extract signature
     * @param output where to write the changed document
     * @throws IOException
     */
    private void doValidation(String filename, OutputStream output) throws IOException
    {
        certInformationHelper = new CertInformationCollector();
        CertSignatureInformation certInfo = null;
        try
        {
            PDSignature signature = SigUtils.getLastRelevantSignature(document);
            if (signature != null)
            {
                certInfo = certInformationHelper.getLastCertInfo(signature, filename);
                signDate = signature.getSignDate();
                if ("ETSI.RFC3161".equals(signature.getSubFilter()))
                {
                    byte[] contents = signature.getContents();
                    TimeStampToken timeStampToken = new TimeStampToken(new CMSSignedData(contents));
                    TimeStampTokenInfo timeStampInfo = timeStampToken.getTimeStampInfo();
                    signDate = Calendar.getInstance();
                    signDate.setTime(timeStampInfo.getGenTime());
                }
            }
        }
        catch (TSPException | CMSException | CertificateProccessingException e)
        {
            throw new IOException("An Error occurred processing the Signature", e);
        }
        if (certInfo == null)
        {
            throw new IOException(
                    "No Certificate information or signature found in the given document");
        }

        PDDocumentCatalog docCatalog = document.getDocumentCatalog();
        COSDictionary catalog = docCatalog.getCOSObject();
        catalog.setNeedToBeUpdated(true);

        COSDictionary dss = getOrCreateDictionaryEntry(COSDictionary.class, catalog, "DSS");

        addExtensions(docCatalog);

        vriBase = getOrCreateDictionaryEntry(COSDictionary.class, dss, "VRI");

        ocsps = getOrCreateDictionaryEntry(COSArray.class, dss, "OCSPs");

        crls = getOrCreateDictionaryEntry(COSArray.class, dss, "CRLs");

        certs = getOrCreateDictionaryEntry(COSArray.class, dss, "Certs");

        addRevocationData(certInfo);

        addAllCertsToCertArray();

        // write incremental
        document.saveIncremental(output);
    }

    /**
     * Gets or creates a dictionary entry. If existing checks for the type and sets need to be
     * updated.
     *
     * @param clazz the class of the dictionary entry, must implement COSUpdateInfo
     * @param parent where to find the element
     * @param name of the element
     * @return a Element of given class, new or existing
     * @throws IOException when the type of the element is wrong
     */
    private static <T extends COSBase & COSUpdateInfo> T getOrCreateDictionaryEntry(Class<T> clazz,
            COSDictionary parent, String name) throws IOException
    {
        T result;
        COSBase element = parent.getDictionaryObject(name);
        if (element != null && clazz.isInstance(element))
        {
            result = clazz.cast(element);
            result.setNeedToBeUpdated(true);
        }
        else if (element != null)
        {
            throw new IOException("Element " + name + " from dictionary is not of type "
                    + clazz.getCanonicalName());
        }
        else
        {
            try
            {
                result = clazz.getDeclaredConstructor().newInstance();
            }
            catch (ReflectiveOperationException | SecurityException e)
            {
                throw new IOException("Failed to create new instance of " + clazz.getCanonicalName(), e);
            }
            result.setDirect(false);
            parent.setItem(COSName.getPDFName(name), result);
        }
        return result;
    }

    /**
     * Fetches and adds revocation information based on the certInfo to the DSS.
     *
     * @param certInfo Certificate information from CertInformationHelper containing certificate
     * chains.
     * @throws IOException
     */
    private void addRevocationData(CertSignatureInformation certInfo) throws IOException
    {
        COSDictionary vri = new COSDictionary();
        vriBase.setItem(certInfo.getSignatureHash(), vri);

        updateVRI(certInfo, vri);

        if (certInfo.getTsaCerts() != null)
        {
            // Don't add RevocationInfo from tsa to VRI's
            correspondingOCSPs = null;
            correspondingCRLs = null;
            addRevocationDataRecursive(certInfo.getTsaCerts());
        }
    }

    /**
     * Tries to get Revocation Data (first OCSP, else CRL) from the given Certificate Chain.
     *
     * @param certInfo from which to fetch revocation data. Will work recursively through its
     * chains.
     * @throws IOException when failed to fetch an revocation data.
     */
    private void addRevocationDataRecursive(CertSignatureInformation certInfo) throws IOException
    {
        if (certInfo.isSelfSigned())
        {
            return;
        }
        // To avoid getting same revocation information twice.
        boolean isRevocationInfoFound = foundRevocationInformation.contains(certInfo.getCertificate());
        if (!isRevocationInfoFound)
        {
            if (certInfo.getOcspUrl() != null && !certInfo.getIssuerCertificates().isEmpty())
            {
                isRevocationInfoFound = fetchOcspData(certInfo);
            }
            if (!isRevocationInfoFound && certInfo.getCrlUrl() != null)
            {
                fetchCrlData(certInfo);
                isRevocationInfoFound = true;
            }

            if (certInfo.getOcspUrl() == null && certInfo.getCrlUrl() == null)
            {
                LOG.info("No revocation information for cert {}",
                        certInfo.getCertificate().getSubjectX500Principal());
            }
            else if (!isRevocationInfoFound)
            {
                throw new IOException("Could not fetch Revocation Info for Cert: "
                        + certInfo.getCertificate().getSubjectX500Principal());
            }
        }

        if (certInfo.getAlternativeCertChain() != null)
        {
            addRevocationDataRecursive(certInfo.getAlternativeCertChain());
        }

        if (certInfo.getCertChain() != null && certInfo.getCertChain().getCertificate() != null)
        {
            addRevocationDataRecursive(certInfo.getCertChain());
        }
    }

    /**
     * Tries to fetch and add OCSP Data to its containers.
     *
     * @param certInfo the certificate info, for it to check OCSP data.
     * @return true when the OCSP data has successfully been fetched and added
     * @throws IOException when Certificate is revoked.
     */
    private boolean fetchOcspData(CertSignatureInformation certInfo) throws IOException
    {
        try
        {
            addOcspData(certInfo);
            return true;
        }
        catch (OCSPException | CertificateProccessingException | IOException | URISyntaxException e)
        {
            LOG.error("Failed fetching OCSP at '{}' for '{}'", certInfo.getOcspUrl(), 
                    certInfo.getCertificate().getSubjectX500Principal(), e);
            return false;
        }
        catch (RevokedCertificateException e)
        {
            throw new IOException(e);
        }
    }

    /**
     * Tries to fetch and add CRL Data to its containers.
     *
     * @param certInfo the certificate info, for it to check CRL data.
     * @throws IOException when failed to fetch, because no validation data could be fetched for
     * data.
     */
    private void fetchCrlData(CertSignatureInformation certInfo) throws IOException
    {
        try
        {
            addCrlRevocationInfo(certInfo);
        }
        catch (GeneralSecurityException | IOException | URISyntaxException |
               RevokedCertificateException | CertificateVerificationException e)
        {
            LOG.warn("Failed fetching CRL", e);
            throw new IOException(e);
        }
    }

    /**
     * Fetches and adds OCSP data to storage for the given Certificate.
     * 
     * @param certInfo the certificate info, for it to check OCSP data.
     * @throws IOException
     * @throws OCSPException
     * @throws CertificateProccessingException
     * @throws RevokedCertificateException
     */
    private void addOcspData(CertSignatureInformation certInfo) throws IOException, OCSPException,
            CertificateProccessingException, RevokedCertificateException, URISyntaxException
    {
        X509Certificate certificate = certInfo.getCertificate();
        if (ocspChecked.contains(certificate))
        {
            // This certificate has been OCSP-checked before
            return;
        }
        for (X509Certificate issuerCertificate : certInfo.getIssuerCertificates())
        {
            addOcspData(certificate, issuerCertificate, certInfo.getOcspUrl());
        }
    }

    private void addOcspData(X509Certificate certificate, X509Certificate issuerCertificate, String ocspURL)
            throws IOException, OCSPException, CertificateProccessingException,
            RevokedCertificateException, URISyntaxException
    {
        OcspHelper ocspHelper = new OcspHelper(
                certificate,
                signDate.getTime(),
                issuerCertificate,
                new HashSet<>(certInformationHelper.getCertificateSet()),
                ocspURL);
        OCSPResp ocspResp = ocspHelper.getResponseOcsp();
        ocspChecked.add(certificate);
        BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResp.getResponseObject();
        X509Certificate ocspResponderCertificate = ocspHelper.getOcspResponderCertificate();
        certInformationHelper.addAllCertsFromHolders(basicResponse.getCerts());
        byte[] signatureHash;
        try
        {
            // https://www.etsi.org/deliver/etsi_ts/102700_102799/10277804/01.01.02_60/ts_10277804v010102p.pdf
            // "For the signatures of the CRL and OCSP response, it is the respective signature
            // object represented as a BER-encoded OCTET STRING encoded with primitive encoding"
            BEROctetString encodedSignature = new BEROctetString(basicResponse.getSignature());
            signatureHash = MessageDigest.getInstance("SHA-1").digest(encodedSignature.getEncoded());
        }
        catch (NoSuchAlgorithmException ex)
        {
            throw new CertificateProccessingException(ex);
        }
        String signatureHashHex = Hex.getString(signatureHash);

        if (!vriBase.containsKey(signatureHashHex))
        {
            COSArray savedCorrespondingOCSPs = correspondingOCSPs;
            COSArray savedCorrespondingCRLs = correspondingCRLs;

            COSDictionary vri = new COSDictionary();
            vriBase.setItem(signatureHashHex, vri);
            CertSignatureInformation ocspCertInfo = certInformationHelper.getCertInfo(ocspResponderCertificate);

            updateVRI(ocspCertInfo, vri);

            correspondingOCSPs = savedCorrespondingOCSPs;
            correspondingCRLs = savedCorrespondingCRLs;
        }

        byte[] ocspData = ocspResp.getEncoded();

        COSStream ocspStream = writeDataToStream(ocspData);
        ocsps.add(ocspStream);
        if (correspondingOCSPs != null)
        {
            correspondingOCSPs.add(ocspStream);
        }
        foundRevocationInformation.add(certificate);
    }

    /**
     * Fetches and adds CRL data to storage for the given Certificate.
     * 
     * @param certInfo the certificate info, for it to check CRL data.
     * @throws IOException
     * @throws URISyntaxException
     * @throws RevokedCertificateException
     * @throws GeneralSecurityException
     * @throws CertificateVerificationException 
     */
    private void addCrlRevocationInfo(CertSignatureInformation certInfo)
            throws IOException, RevokedCertificateException, GeneralSecurityException,
            CertificateVerificationException, URISyntaxException
    {
        X509CRL crl = CRLVerifier.downloadCRLFromWeb(certInfo.getCrlUrl());

        // find the issuer certificate (usually issuer of signature certificate)
        X509Certificate issuerCertificate = null;
        for (X509Certificate certificate : certInformationHelper.getCertificateSet())
        {
            if (certificate.getSubjectX500Principal().equals(crl.getIssuerX500Principal()))
            {
                issuerCertificate = certificate;
                break;
            }
        }
        if (issuerCertificate == null)
        {
            throw new CertificateVerificationException("Can't find issuer of CRL for " + certInfo.getCrlUrl());
        }
        crl.verify(issuerCertificate.getPublicKey(), SecurityProvider.getProvider().getName());
        CRLVerifier.checkRevocation(crl, certInfo.getCertificate(), signDate.getTime(), certInfo.getCrlUrl());
        COSStream crlStream = writeDataToStream(crl.getEncoded());
        crls.add(crlStream);
        if (correspondingCRLs != null)
        {
            correspondingCRLs.add(crlStream);

            byte[] signatureHash;
            try
            {
                // https://www.etsi.org/deliver/etsi_ts/102700_102799/10277804/01.01.02_60/ts_10277804v010102p.pdf
                // "For the signatures of the CRL and OCSP response, it is the respective signature
                // object represented as a BER-encoded OCTET STRING encoded with primitive encoding"
                BEROctetString berEncodedSignature = new BEROctetString(crl.getSignature());
                signatureHash = MessageDigest.getInstance("SHA-1").digest(berEncodedSignature.getEncoded());
            }
            catch (NoSuchAlgorithmException ex)
            {
                throw new CertificateVerificationException(ex.getMessage(), ex);
            }
            String signatureHashHex = Hex.getString(signatureHash);

            if (!vriBase.containsKey(signatureHashHex))
            {
                COSArray savedCorrespondingOCSPs = correspondingOCSPs;
                COSArray savedCorrespondingCRLs = correspondingCRLs;

                COSDictionary vri = new COSDictionary();
                vriBase.setItem(signatureHashHex, vri);

                CertSignatureInformation crlCertInfo;
                try
                {
                    crlCertInfo = certInformationHelper.getCertInfo(issuerCertificate);
                }
                catch (CertificateProccessingException ex)
                {
                    throw new CertificateVerificationException(ex.getMessage(), ex);
                }

                updateVRI(crlCertInfo, vri);

                correspondingOCSPs = savedCorrespondingOCSPs;
                correspondingCRLs = savedCorrespondingCRLs;
            }
        }
        foundRevocationInformation.add(certInfo.getCertificate());
    }

    private void updateVRI(CertSignatureInformation certInfo, COSDictionary vri) throws IOException
    {
        if (certInfo.getCertificate().getExtensionValue(OCSPObjectIdentifiers.id_pkix_ocsp_nocheck.getId()) == null)
        {
            correspondingOCSPs = new COSArray();
            correspondingCRLs = new COSArray();
            addRevocationDataRecursive(certInfo);
            if (!correspondingOCSPs.isEmpty())
            {
                vri.setItem(COSName.OCSP, correspondingOCSPs);
            }
            if (!correspondingCRLs.isEmpty())
            {
                vri.setItem(COSName.CRL, correspondingCRLs);
            }
        }

        COSArray correspondingCerts = new COSArray();
        CertSignatureInformation ci = certInfo;
        do
        {
            X509Certificate cert = ci.getCertificate();
            try
            {
                COSStream certStream = writeDataToStream(cert.getEncoded());
                correspondingCerts.add(certStream);
                certMap.put(cert, certStream);
            }
            catch (CertificateEncodingException ex)
            {
                // should not happen because these are existing certificates
                LOG.error(ex, ex);
            }

            if (cert.getExtensionValue(OCSPObjectIdentifiers.id_pkix_ocsp_nocheck.getId()) != null)
            {
                break;
            }
            ci = ci.getCertChain();
        }
        while (ci != null);
        vri.setItem(COSName.CERT, correspondingCerts);

        vri.setDate(COSName.TU, Calendar.getInstance());
    }

    /**
     * Adds all certs to the certs-array. Make sure that all certificates are inside the
     * certificateStore of certInformationHelper. This should be the only call to fill certs.
     *
     * @throws IOException
     */
    private void addAllCertsToCertArray() throws IOException
    {
        for (X509Certificate cert : certInformationHelper.getCertificateSet())
        {
            if (!certMap.containsKey(cert))
            {
                try
                {
                    COSStream certStream = writeDataToStream(cert.getEncoded());
                    certMap.put(cert, certStream);
                }
                catch (CertificateEncodingException ex)
                {
                    throw new IOException(ex);
                }
            }
        }
        certMap.values().forEach(certStream -> certs.add(certStream));
    }

    /**
     * Creates a Flate encoded <code>COSStream</code> object with the given data.
     * 
     * @param data to write into the COSStream
     * @return COSStream a COSStream object that can be added to the document
     * @throws IOException
     */
    private COSStream writeDataToStream(byte[] data) throws IOException
    {
        COSStream stream = document.getDocument().createCOSStream();
        try (OutputStream os = stream.createOutputStream(COSName.FLATE_DECODE))
        {
            os.write(data);
        }
        return stream;
    }

    /**
     * Adds Extensions to the document catalog. So that the use of DSS is identified. Described in
     * PAdES Part 4, Chapter 4.4.
     *
     * @param catalog to add Extensions into
     */
    private void addExtensions(PDDocumentCatalog catalog)
    {
        COSDictionary dssExtensions = new COSDictionary();
        dssExtensions.setDirect(true);
        catalog.getCOSObject().setItem(COSName.EXTENSIONS, dssExtensions);

        COSDictionary adbeExtension = new COSDictionary();
        adbeExtension.setDirect(true);
        dssExtensions.setItem(COSName.ADBE, adbeExtension);

        adbeExtension.setName(COSName.BASE_VERSION, "1.7");
        adbeExtension.setInt(COSName.EXTENSION_LEVEL, 5);

        catalog.setVersion("1.7");
    }

    public static void main(String[] args) throws IOException
    {
        if (args.length != 1)
        {
            usage();
            System.exit(1);
        }

        // register BouncyCastle provider, needed for "exotic" algorithms
        Security.addProvider(SecurityProvider.getProvider());

        // add ocspInformation
        AddValidationInformation addOcspInformation = new AddValidationInformation();

        File inFile = new File(args[0]);
        String name = inFile.getName();
        String substring = name.substring(0, name.lastIndexOf('.'));

        File outFile = new File(inFile.getParent(), substring + "_LTV.pdf");
        addOcspInformation.validateSignature(inFile, outFile);
    }

    private static void usage()
    {
        System.err.println("usage: java " + AddValidationInformation.class.getName() + " "
                + "<pdf_to_add_ocsp>\n");
    }
}