OcspHelper.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.cert;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.Security;
import java.security.cert.CertificateEncodingException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
import java.util.Random;
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.pdmodel.encryption.SecurityProvider;
import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.DLSequence;
import org.bouncycastle.asn1.ocsp.OCSPObjectIdentifiers;
import org.bouncycastle.asn1.ocsp.OCSPResponseStatus;
import org.bouncycastle.asn1.ocsp.ResponderID;
import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.Extensions;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
import org.bouncycastle.cert.ocsp.BasicOCSPResp;
import org.bouncycastle.cert.ocsp.CertificateID;
import org.bouncycastle.cert.ocsp.CertificateStatus;
import org.bouncycastle.cert.ocsp.OCSPException;
import org.bouncycastle.cert.ocsp.OCSPReq;
import org.bouncycastle.cert.ocsp.OCSPReqBuilder;
import org.bouncycastle.cert.ocsp.OCSPResp;
import org.bouncycastle.cert.ocsp.RevokedStatus;
import org.bouncycastle.cert.ocsp.SingleResp;
import org.bouncycastle.operator.ContentVerifierProvider;
import org.bouncycastle.operator.DigestCalculator;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;

/**
 * Helper Class for OCSP-Operations with bouncy castle.
 * 
 * @author Alexis Suter
 */
public class OcspHelper
{
    private static final Logger LOG = LogManager.getLogger(OcspHelper.class);

    private final X509Certificate issuerCertificate;
    private final Date signDate;
    private final X509Certificate certificateToCheck;
    private final Set<X509Certificate> additionalCerts;
    private final String ocspUrl;
    private DEROctetString encodedNonce;
    private X509Certificate ocspResponderCertificate;
    private final JcaX509CertificateConverter certificateConverter = new JcaX509CertificateConverter();
    
    // SecureRandom.getInstanceStrong() would be better, but sometimes blocks on Linux
    private static final Random RANDOM = new SecureRandom();

    /**
     * @param checkCertificate Certificate to be OCSP-checked
     * @param signDate the date when the signing took place
     * @param issuerCertificate Certificate of the issuer
     * @param additionalCerts Set of trusted root CA certificates that will be used as "trust
     * anchors" and intermediate CA certificates that will be used as part of the certification
     * chain. All self-signed certificates are considered to be trusted root CA certificates. All
     * the rest are considered to be intermediate CA certificates.
     * @param ocspUrl where to fetch for OCSP
     */
    public OcspHelper(X509Certificate checkCertificate, Date signDate, X509Certificate issuerCertificate,
            Set<X509Certificate> additionalCerts, String ocspUrl)
    {
        this.certificateToCheck = checkCertificate;
        this.signDate = signDate;
        this.issuerCertificate = issuerCertificate;
        this.additionalCerts = additionalCerts;
        this.ocspUrl = ocspUrl;
    }

    /**
     * Get the certificate to be OCSP-checked.
     * 
     * @return The certificate to be OCSP-checked.
     */
    X509Certificate getCertificateToCheck()
    {
        return certificateToCheck;
    }

    /**
     * Performs and verifies the OCSP-Request
     *
     * @return the OCSPResp, when the request was successful, else a corresponding exception will be
     * thrown. Never returns null.
     *
     * @throws IOException
     * @throws OCSPException
     * @throws RevokedCertificateException
     * @throws URISyntaxException
     */
    public OCSPResp getResponseOcsp()
            throws IOException, OCSPException, RevokedCertificateException, URISyntaxException
    {
        OCSPResp ocspResponse = performRequest(ocspUrl);
        verifyOcspResponse(ocspResponse);
        return ocspResponse;
    }

    /**
     * Get responder certificate. This is available after {@link #getResponseOcsp()} has been
     * called. This method should be used instead of {@code basicResponse.getCerts()[0]}
     *
     * @return The certificate of the responder.
     */
    public X509Certificate getOcspResponderCertificate()
    {
        return ocspResponderCertificate;
    }

    /**
     * Verifies the status and the response itself (including nonce), but not the signature.
     * 
     * @param ocspResponse to be verified
     * @throws OCSPException
     * @throws RevokedCertificateException
     * @throws IOException if the default security provider can't be instantiated
     */
    private void verifyOcspResponse(OCSPResp ocspResponse)
            throws OCSPException, RevokedCertificateException, IOException
    {
        verifyRespStatus(ocspResponse);

        BasicOCSPResp basicResponse = (BasicOCSPResp) ocspResponse.getResponseObject();
        if (basicResponse != null)
        {
            ResponderID responderID = basicResponse.getResponderId().toASN1Primitive();
            // https://tools.ietf.org/html/rfc6960#section-4.2.2.3
            // The basic response type contains:
            // (...)
            // either the name of the responder or a hash of the responder's
            // public key as the ResponderID
            // (...)
            // The responder MAY include certificates in the certs field of
            // BasicOCSPResponse that help the OCSP client verify the responder's
            // signature.
            X500Name name = responderID.getName();
            if (name != null)
            {
                findResponderCertificateByName(basicResponse, name);
            }
            else
            {
                byte[] keyHash = responderID.getKeyHash();
                if (keyHash != null)
                {
                    findResponderCertificateByKeyHash(basicResponse, keyHash);
                }
                else
                {
                    throw new OCSPException("OCSP: basic response must provide name or key hash");
                }
            }

            if (ocspResponderCertificate == null)
            {
                throw new OCSPException("OCSP: certificate for responder " + name + " not found");
            }

            try
            {
                SigUtils.checkResponderCertificateUsage(ocspResponderCertificate);
            }
            catch (CertificateParsingException ex)
            {
                // unlikely to happen because the certificate existed as an object
                LOG.error(ex, ex);
            }
            checkOcspSignature(ocspResponderCertificate, basicResponse);

            boolean nonceChecked = checkNonce(basicResponse);

            SingleResp[] responses = basicResponse.getResponses();
            if (responses.length != 1)
            {
                throw new OCSPException(
                        "OCSP: Received " + responses.length + " responses instead of 1!");
            }

            SingleResp resp = responses[0];
            Object status = resp.getCertStatus();

            if (!nonceChecked)
            {
                // https://tools.ietf.org/html/rfc5019
                // fall back to validating the OCSPResponse based on time
                checkOcspResponseFresh(resp);
            }

            if (status instanceof RevokedStatus)
            {
                RevokedStatus revokedStatus = (RevokedStatus) status;
                if (revokedStatus.getRevocationTime().compareTo(signDate) <= 0)
                {
                    throw new RevokedCertificateException(
                        "OCSP: Certificate is revoked since " +
                                revokedStatus.getRevocationTime(),
                                revokedStatus.getRevocationTime());
                }
                LOG.info("The certificate was revoked after signing by OCSP {} on {}", ocspUrl,
                        revokedStatus.getRevocationTime());
            }
            else if (status != CertificateStatus.GOOD)
            {
                throw new OCSPException("OCSP: Status of Cert is unknown");
            }
        }
    }

    private byte[] getKeyHashFromCertHolder(X509CertificateHolder certHolder)
    {
        // https://tools.ietf.org/html/rfc2560#section-4.2.1
        // KeyHash ::= OCTET STRING -- SHA-1 hash of responder's public key
        //         -- (i.e., the SHA-1 hash of the value of the
        //         -- BIT STRING subjectPublicKey [excluding
        //         -- the tag, length, and number of unused
        //         -- bits] in the responder's certificate)

        // code below inspired by org.bouncycastle.cert.ocsp.CertificateID.createCertID()
        // tested with SO52757037-Signed3-OCSP-with-KeyHash.pdf
        SubjectPublicKeyInfo info = certHolder.getSubjectPublicKeyInfo();
        try
        {
            return MessageDigest.getInstance("SHA-1").digest(info.getPublicKeyData().getBytes());
        }
        catch (NoSuchAlgorithmException ex)
        {
            // should not happen
            LOG.error("SHA-1 Algorithm not found", ex);
            return new byte[0];
        }
    }

    private void findResponderCertificateByKeyHash(BasicOCSPResp basicResponse, byte[] keyHash)
            throws IOException
    {
        X509CertificateHolder[] certHolders = basicResponse.getCerts();
        for (X509CertificateHolder certHolder : certHolders)
        {
            byte[] digest = getKeyHashFromCertHolder(certHolder);
            if (Arrays.equals(keyHash, digest))
            {
                try
                {
                    ocspResponderCertificate = certificateConverter.getCertificate(certHolder);
                    return;
                }
                catch (CertificateException ex)
                {
                    // unlikely to happen because the certificate existed as an object
                    LOG.error(ex, ex);
                }
                break;
            }
        }

        // DO NOT use the certificate found in additionalCerts first. One file had a
        // responder certificate in the PDF itself with SHA1withRSA algorithm, but
        // the responder delivered a different (newer, more secure) certificate
        // with SHA256withRSA (tried with QV_RCA1_RCA3_CPCPS_V4_11.pdf)
        // https://www.quovadisglobal.com/~/media/Files/Repository/QV_RCA1_RCA3_CPCPS_V4_11.ashx
        for (X509Certificate cert : additionalCerts)
        {
            try
            {
                byte[] digest = getKeyHashFromCertHolder(new X509CertificateHolder(cert.getEncoded()));
                if (Arrays.equals(keyHash, digest))
                {
                    ocspResponderCertificate = cert;
                    return;
                }
            }
            catch (CertificateEncodingException ex)
            {
                // unlikely to happen because the certificate existed as an object
                LOG.error(ex, ex);
            }
        }
    }

    private void findResponderCertificateByName(BasicOCSPResp basicResponse, X500Name name)
    {
        X509CertificateHolder[] certHolders = basicResponse.getCerts();
        for (X509CertificateHolder certHolder : certHolders)
        {
            if (name.equals(certHolder.getSubject()))
            {
                try
                {
                    ocspResponderCertificate = certificateConverter.getCertificate(certHolder);
                    return;
                }
                catch (CertificateException ex)
                {
                    // unlikely to happen because the certificate existed as an object
                    LOG.error(ex, ex);
                }
            }
        }

        // DO NOT use the certificate found in additionalCerts first. One file had a
        // responder certificate in the PDF itself with SHA1withRSA algorithm, but
        // the responder delivered a different (newer, more secure) certificate
        // with SHA256withRSA (tried with QV_RCA1_RCA3_CPCPS_V4_11.pdf)
        // https://www.quovadisglobal.com/~/media/Files/Repository/QV_RCA1_RCA3_CPCPS_V4_11.ashx
        for (X509Certificate cert : additionalCerts)
        {
            X500Name certSubjectName = new X500Name(cert.getSubjectX500Principal().getName());
            if (certSubjectName.equals(name))
            {
                ocspResponderCertificate = cert;
                return;
            }
        }
    }

    private void checkOcspResponseFresh(SingleResp resp) throws OCSPException
    {
        // https://tools.ietf.org/html/rfc5019
        // Clients MUST check for the existence of the nextUpdate field and MUST
        // ensure the current time, expressed in GMT time as described in
        // Section 2.2.4, falls between the thisUpdate and nextUpdate times.  If
        // the nextUpdate field is absent, the client MUST reject the response.

        Date curDate = Calendar.getInstance().getTime();

        Date thisUpdate = resp.getThisUpdate();
        if (thisUpdate == null)
        {
            throw new OCSPException("OCSP: thisUpdate field is missing in response (RFC 5019 2.2.4.)");
        }
        Date nextUpdate = resp.getNextUpdate();
        if (nextUpdate == null)
        {
            throw new OCSPException("OCSP: nextUpdate field is missing in response (RFC 5019 2.2.4.)");
        }
        if (curDate.compareTo(thisUpdate) < 0)
        {
            LOG.error("{} < {}", curDate, thisUpdate);
            throw new OCSPException("OCSP: current date < thisUpdate field (RFC 5019 2.2.4.)");
        }
        if (curDate.compareTo(nextUpdate) > 0)
        {
            LOG.error("{} > {}", curDate, nextUpdate);
            throw new OCSPException("OCSP: current date > nextUpdate field (RFC 5019 2.2.4.)");
        }
        LOG.info("OCSP response is fresh");
    }

    /**
     * Checks whether the OCSP response is signed by the given certificate.
     * 
     * @param certificate the certificate to check the signature
     * @param basicResponse OCSP response containing the signature
     * @throws OCSPException when the signature is invalid or could not be checked
     * @throws IOException if the default security provider can't be instantiated
     */
    private void checkOcspSignature(X509Certificate certificate, BasicOCSPResp basicResponse)
            throws OCSPException
    {
        try
        {
            ContentVerifierProvider verifier = new JcaContentVerifierProviderBuilder()
                    .setProvider(SecurityProvider.getProvider()).build(certificate);

            if (!basicResponse.isSignatureValid(verifier))
            {
                throw new OCSPException("OCSP-Signature is not valid!");
            }
        }
        catch (OperatorCreationException e)
        {
            throw new OCSPException("Error checking Ocsp-Signature", e);
        }
    }

    /**
     * Checks if the nonce in the response matches.
     * 
     * @param basicResponse Response to be checked
     * @return true if the nonce is present and matches, false if nonce is missing.
     * @throws OCSPException if the nonce is different
     */
    private boolean checkNonce(BasicOCSPResp basicResponse) throws OCSPException
    {
        Extension nonceExt = basicResponse.getExtension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce);
        if (nonceExt != null)
        {
            DEROctetString responseNonceString = (DEROctetString) nonceExt.getExtnValue();
            if (!responseNonceString.equals(encodedNonce))
            {
                throw new OCSPException("Different nonce found in response!");
            }
            else
            {
                LOG.info("Nonce is good");
                return true;
            }
        }
        // https://tools.ietf.org/html/rfc5019
        // Clients that opt to include a nonce in the
        // request SHOULD NOT reject a corresponding OCSPResponse solely on the
        // basis of the nonexistent expected nonce, but MUST fall back to
        // validating the OCSPResponse based on time.
        return false;
    }

    /**
     * Performs the OCSP-Request, with given data.
     *
     * @param urlString URL of OCSP service.
     * @return the OCSPResp, that has been fetched from the ocspUrl
     * @throws IOException
     * @throws OCSPException
     * @throws URISyntaxException
     */
    private OCSPResp performRequest(String urlString)
            throws IOException, OCSPException, URISyntaxException
    {
        OCSPReq request = generateOCSPRequest();
        URL url = new URI(urlString).toURL();
        HttpURLConnection httpConnection = (HttpURLConnection) url.openConnection();
        try
        {
            httpConnection.setRequestProperty("Content-Type", "application/ocsp-request");
            httpConnection.setRequestProperty("Accept", "application/ocsp-response");
            httpConnection.setRequestMethod("POST");
            httpConnection.setDoOutput(true);
            try (OutputStream out = httpConnection.getOutputStream())
            {
                out.write(request.getEncoded());
            }

            int responseCode = httpConnection.getResponseCode();
            if (responseCode == HttpURLConnection.HTTP_MOVED_TEMP ||
                responseCode == HttpURLConnection.HTTP_MOVED_PERM ||
                responseCode == HttpURLConnection.HTTP_SEE_OTHER)
            {
                String location = httpConnection.getHeaderField("Location");
                if (urlString.startsWith("http://") &&
                    location.startsWith("https://") &&
                    urlString.substring(7).equals(location.substring(8)))
                {
                    // redirection from http:// to https://
                    // change this code if you want to be more flexible (but think about security!)
                    LOG.info("redirection to {} followed", location);
                    return performRequest(location);
                }
                else
                {
                    LOG.info("redirection to {} ignored", location);
                }
            }
            if (responseCode != HttpURLConnection.HTTP_OK)
            {
                throw new IOException("OCSP: Could not access url, ResponseCode "
                        + httpConnection.getResponseCode() + ": "
                        + httpConnection.getResponseMessage());
            }
            // Get response
            try (InputStream in = (InputStream) httpConnection.getContent())
            {
                return new OCSPResp(in);
            }
        }
        finally
        {
            httpConnection.disconnect();
        }
    }

    /**
     * Helper method to verify response status.
     * 
     * @param resp OCSP response
     * @throws OCSPException if the response status is not ok
     */
    public void verifyRespStatus(OCSPResp resp) throws OCSPException
    {
        String statusInfo = "";
        if (resp != null)
        {
            int status = resp.getStatus();
            switch (status)
            {
            case OCSPResponseStatus.INTERNAL_ERROR:
                statusInfo = "INTERNAL_ERROR";
                LOG.error("An internal error occurred in the OCSP Server!");
                break;
            case OCSPResponseStatus.MALFORMED_REQUEST:
                // This happened when the "critical" flag was used for extensions
                // on a responder known by the committer of this comment.
                statusInfo = "MALFORMED_REQUEST";
                LOG.error("Your request did not fit the RFC 2560 syntax!");
                break;
            case OCSPResponseStatus.SIG_REQUIRED:
                statusInfo = "SIG_REQUIRED";
                LOG.error("Your request was not signed!");
                break;
            case OCSPResponseStatus.TRY_LATER:
                statusInfo = "TRY_LATER";
                LOG.error("The server was too busy to answer you!");
                break;
            case OCSPResponseStatus.UNAUTHORIZED:
                statusInfo = "UNAUTHORIZED";
                LOG.error("The server could not authenticate you!");
                break;
            case OCSPResponseStatus.SUCCESSFUL:
                break;
            default:
                statusInfo = "UNKNOWN";
                LOG.error("Unknown OCSPResponse status code! {}", status);
            }
        }
        if (resp == null || resp.getStatus() != OCSPResponseStatus.SUCCESSFUL)
        {
            throw new OCSPException("OCSP response unsuccessful, status: " + statusInfo);
        }
    }

    /**
     * Generates an OCSP request and generates the <code>CertificateID</code>.
     *
     * @return OCSP request, ready to fetch data
     * @throws OCSPException
     * @throws IOException
     */
    private OCSPReq generateOCSPRequest() throws OCSPException, IOException
    {
        Security.addProvider(SecurityProvider.getProvider());

        // Generate the ID for the certificate we are looking for
        CertificateID certId;
        try
        {
            certId = new CertificateID(new SHA1DigestCalculator(),
                    new JcaX509CertificateHolder(issuerCertificate),
                    certificateToCheck.getSerialNumber());
        }
        catch (CertificateEncodingException e)
        {
            throw new IOException("Error creating CertificateID with the Certificate encoding", e);
        }

        // https://tools.ietf.org/html/rfc2560#section-4.1.2
        // Support for any specific extension is OPTIONAL. The critical flag
        // SHOULD NOT be set for any of them.

        Extension responseExtension = new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_response,
                false, new DLSequence(OCSPObjectIdentifiers.id_pkix_ocsp_basic).getEncoded());

        encodedNonce = new DEROctetString(new DEROctetString(create16BytesNonce()));
        Extension nonceExtension = new Extension(OCSPObjectIdentifiers.id_pkix_ocsp_nonce, false,
                encodedNonce);

        OCSPReqBuilder builder = new OCSPReqBuilder();
        builder.setRequestExtensions(
                new Extensions(new Extension[] { responseExtension, nonceExtension }));
        builder.addRequest(certId);
        return builder.build();
    }

    private byte[] create16BytesNonce()
    {
        byte[] nonce = new byte[16];
        RANDOM.nextBytes(nonce);
        return nonce;
    }

    /**
     * Class to create SHA-1 Digest, used for creation of CertificateID.
     */
    private static class SHA1DigestCalculator implements DigestCalculator
    {
        private final ByteArrayOutputStream bOut = new ByteArrayOutputStream();

        @Override
        public AlgorithmIdentifier getAlgorithmIdentifier()
        {
            return new AlgorithmIdentifier(OIWObjectIdentifiers.idSHA1);
        }

        @Override
        public OutputStream getOutputStream()
        {
            return bOut;
        }

        @Override
        public byte[] getDigest()
        {
            byte[] bytes = bOut.toByteArray();
            bOut.reset();

            try
            {
                MessageDigest md = MessageDigest.getInstance("SHA-1");
                return md.digest(bytes);
            }
            catch (NoSuchAlgorithmException ex)
            {
                // should not happen
                LOG.error("SHA-1 Algorithm not found", ex);
                return new byte[0];
            }
        }
    }
}