0

Problem description

We have a specific business / technical case - we need to electronically sign PDFs, and to do that we're partnering with a company that handles electronic signature over the hash of a PDF.

We more or less know how to get the hash from the PDF and merge it back after signing by an external service via Rest API - but something along this process causes the PDF to show "Document has been altered or corrupted since it was signed".

Resulting PDF with broken signature

How we're doing it

We are using library Apache PDFBox version 3.0.0 (and we tried with different versions as well) and we're basing our solution on the official Apache Examples:

https://svn.apache.org/viewvc/pdfbox/trunk/examples/src/main/java/org/apache/pdfbox/examples/signature/

  • CreateSignature
  • CreateSignatureBase

We've read many posts on stackoverlow that describe problems similar to ours, for example:

And we settled on the solution described here: Java PdfBox - PDF Sign Problem -External Signature -Invalid signature There are errors in formatting or in the information contained in this signature

Our main class - CreateSignature

package TOTPSignPDF.Service;

import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.ExternalSigningSupport;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.cert.jcajce.JcaCertStore;
import org.bouncycastle.cms.CMSException;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.CMSSignedDataGenerator;
import org.bouncycastle.cms.CMSTypedData;
import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Base64;
import java.util.Calendar;

/*
    This solution was based on official examples from Apache:
    https://svn.apache.org/viewvc/pdfbox/trunk/examples/src/main/java/org/apache/pdfbox/examples/signature/CreateSignature.java?revision=1899086&view=markup
    https://svn.apache.org/viewvc/pdfbox/trunk/examples/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java?view=markup
 */

@Service
public class CreateSignature {

    private static final Logger LOG = LoggerFactory.getLogger(CreateSignature.class);

    //@Autowired
    //ServerSignature serverSignature;

    @Autowired
    ExternalSignature externalSignature;

    private Certificate[] certificateChain;

    /**
     * Signs the given PDF file.
     *
     * @param inFile the original PDF file
     */
    public void signDocument(File inFile) throws IOException {

        // we're being given the certificate chain with public key
        setCertificateChain(externalSignature.getCertificateChain());

        String name = inFile.getName();
        String substring = name.substring(0, name.lastIndexOf('.'));

        File outFile = new File(inFile.getParent(), substring + "_signed.pdf");
        loadPDFAndSign(inFile, outFile);
    }

    private void setCertificateChain(final Certificate[] certificateChain) {
        this.certificateChain = certificateChain;
    }

    /**
     * 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
     */

    private void loadPDFAndSign(File inFile, File outFile) throws IOException {
        if (inFile == null || !inFile.exists()) {
            throw new FileNotFoundException("Document for signing does not exist");
        }

        // sign
        try (FileOutputStream fileOutputStream = new FileOutputStream(outFile);
             PDDocument doc = Loader.loadPDF(inFile)) {
            addSignatureDictionaryAndSignExternally(doc, fileOutputStream);
        }
    }

    private void addSignatureDictionaryAndSignExternally(PDDocument document, OutputStream output)
        throws IOException {

        int accessPermissions = SigUtils.getMDPPermission(document);
        if (accessPermissions == 1) {
            throw new IllegalStateException("No changes to the document are permitted due to DocMDP transform parameters dictionary");
        }

        // create signature dictionary
        PDSignature signature = new PDSignature();
        signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
        signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED);
        signature.setName("Example User");
        signature.setLocation("Los Angeles, CA");
        signature.setReason("Testing");

        // the signing date, needed for valid signature
        signature.setSignDate(Calendar.getInstance());

        // Optional: certify 
        if (accessPermissions == 0) {
            SigUtils.setMDPPermission(document, signature, 2);
        }

        // it was if(isExternalSigning()) {
        document.addSignature(signature);
        ExternalSigningSupport externalSigning = document.saveIncrementalForExternalSigning(output);
        // invoke external signature service - passing the document content with the "empty" signature added
        byte[] cmsSignature = sign(externalSigning.getContent());
        // set signature bytes received from the service
        externalSigning.setSignature(cmsSignature);
        //}

        // call SigUtils.checkCrossReferenceTable(document) if Adobe complains - we're doing it only because I ran out of ideas
        // and read https://stackoverflow.com/a/71293901/535646
        // and https://issues.apache.org/jira/browse/PDFBOX-5382
        SigUtils.checkCrossReferenceTable(document); // no errors here

        //document.close(); not needed because document is defined as auto-closeable resource in the function above
    }

    /**
     * SignatureInterface sample implementation.
     * Use your favorite cryptographic library to implement PKCS #7 signature creation.
     * If you want to create the hash and the signature separately (e.g. to transfer only the hash
     * to an external application), read <a href="https://stackoverflow.com/questions/41767351">this
     * answer</a> or <a href="https://stackoverflow.com/questions/56867465">this answer</a>.
     *
     * @throws IOException
     */
    private byte[] sign(InputStream content) throws IOException {
        try {

            // get the hash of the document with additional signature field
            MessageDigest digest = MessageDigest.getInstance("SHA256", new BouncyCastleProvider());

            byte[] hashBytes = digest.digest(content.readAllBytes());
            String hashBase64 = new String(Base64.getEncoder().encode(hashBytes));
            LOG.info("Digest in Base64: " + hashBase64);

            // call External API to sign the hash - hash of the document with added field for signature
            //byte[] signedHashBytes = serverSignature.sign(hashBase64);
            byte[] signedHashBytes = externalSignature.sign(hashBytes);

            // this lower part of the code is based on this answer:
            // https://stackoverflow.com/questions/69676156/java-pdfbox-pdf-sign-problem-external-signature-invalid-signature-there-are
            //
            // In the standalone application this ContentSigner would be an implementation of signing process,
            // but we are given the signed hash from the External Api and we just have to return it - the same situation
            // as in this stackoverflow post
            ContentSigner nonSigner_signedHashProvided = new ContentSigner() {

                @Override
                public byte[] getSignature() {
                    return signedHashBytes;
                }

                @Override
                public OutputStream getOutputStream() {
                    return new ByteArrayOutputStream();
                }

                @Override
                public AlgorithmIdentifier getAlgorithmIdentifier() {
                    return new DefaultSignatureAlgorithmIdentifierFinder().find("SHA256WithRSA");
                }
            };

            CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
            X509Certificate certificateRepresentingTheSigner = (X509Certificate) certificateChain[0];

            gen.addSignerInfoGenerator(
                new JcaSignerInfoGeneratorBuilder(new JcaDigestCalculatorProviderBuilder().build())
                    .build(
                        nonSigner_signedHashProvided,
                        certificateRepresentingTheSigner
                    )
            );
            gen.addCertificates(new JcaCertStore(Arrays.asList(certificateChain)));

            //CMSProcessableInputStream msg = new CMSProcessableInputStream(content);
            //these don't seem to change anything
            //CMSTypedData msg = new CMSProcessableByteArray(signedHashBytes);
            CMSTypedData msg = new CMSProcessableInputStream(new ByteArrayInputStream("not used".getBytes()));
            CMSSignedData signedData = gen.generate(msg, false);

            return signedData.getEncoded();
        } catch (GeneralSecurityException | CMSException | OperatorCreationException e) {
            throw new IOException(e);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

Class imitating the external hash signing process - ExternalSignature

I created a local keystore with self-signed private key with this command:

keytool -genkeypair -storepass 123456 -storetype pkcs12 -alias testKeystoreForPOC -validity 365 -keystore testKeystore.jks -keyalg RSA -sigalg SHA256withRSA

package TOTPSignPDF.Service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.InvalidKeyException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.SignatureException;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateEncodingException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Base64;
import java.util.Enumeration;

@Service
public class ExternalSignature {
    private static final Logger LOG = LoggerFactory.getLogger(ExternalSignature.class);

    private Certificate[] certificateChain;

    private PrivateKey privateKey;

    public byte[] sign(byte[] bytesToSign) {
        LOG.info("Started signing process");
        try {
            Signature privateSignature = Signature.getInstance("SHA256withRSA");
            privateSignature.initSign(privateKey);
            privateSignature.update(bytesToSign);
            byte[] signature = privateSignature.sign();
            LOG.info("Finished signing process");
            return signature;
        } catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) {
            throw new RuntimeException(e);
        }
    }

    public Certificate[] getCertificateChain(){
        return this.certificateChain;
    }

    @PostConstruct
    private void initializePostConstruct() {
        File keystoreFile = new File("keystore/testKeystore.jks");

        try {
            KeyStore keystore = KeyStore.getInstance("PKCS12");

            char[] password = "123456".toCharArray();
            try (InputStream is = new FileInputStream(keystoreFile)) {
                keystore.load(is, password);
            }

            // grabs the first alias from the keystore and get the private key. An
            // alternative method or constructor could be used for setting a specific
            // alias that should be used.
            Enumeration<String> aliases = keystore.aliases();
            String alias;
            Certificate cert = null;
            while (cert == null && aliases.hasMoreElements()) {
                alias = aliases.nextElement();
                setPrivateKey((PrivateKey) keystore.getKey(alias, password));
                Certificate[] certChain = keystore.getCertificateChain(alias);
                if (certChain != null) {
                    setCertificateChain(certChain);
                    cert = certChain[0];
                    if (cert instanceof X509Certificate) {
                        // avoid expired certificate
                        ((X509Certificate) cert).checkValidity();

                        SigUtils.checkCertificateUsage((X509Certificate) cert);
                    }
                }
            }

            if (cert == null) {
                throw new IOException("Could not find certificate");
            }
        } catch (KeyStoreException | NoSuchAlgorithmException | CertificateException | UnrecoverableKeyException | IOException e) {
            throw new RuntimeException(e);
        }
    }

    public final void setPrivateKey(PrivateKey privateKey) {
        LOG.info("Set new private key");
        String base64OfPrivateKey = new String(Base64.getEncoder().encode(privateKey.getEncoded()));
        LOG.info("base64OfPrivateKey: " + base64OfPrivateKey);
        this.privateKey = privateKey;
    }

    public final void setCertificateChain(final Certificate[] certificateChain) {
        LOG.info("Set new certificate chain, size: " + certificateChain.length);
        try {
            String base64OfPublicKey = new String(Base64.getEncoder().encode(certificateChain[0].getEncoded()));
            LOG.info("base64OfPublicKey: " + base64OfPublicKey);
        } catch (CertificateEncodingException e) {
            throw new RuntimeException(e);
        }
        this.certificateChain = certificateChain;
    }
}

Additional classes that are used from Apache examples:

  • CMSProcessableInputStream
  • SigUtils

Please help us

As far as we understand other posts on this site revolving around similar problems, everything should work, but it doesn't. Most probably we're missing some crucial step.

We’re looking forward to any response, every bit of information could help us.

Something that I don't understand is why the digest (hash) of the PDF is so small: LXl/LHCaxrf7lYlN8d8m7gDNp9DRqY+azvxCS/mB3uY=

Does it look like correct size ?

PDF examples and public / private key

  • PDF after signing

  • PDF before signing

  • Keystore used for signing process

  • Public key in Base64: MIIDUTCCAjmgAwIBAgIEZPgGSjANBgkqhkiG9w0BAQsFADBZMQswCQYDVQQGEwJURTENMAsGA1UECBMEVGVzdDENMAsGA1UEBxMEVGVzdDENMAsGA1UEChMEVGVzdDENMAsGA1UECxMEVGVzdDEOMAwGA1UEAxMFSmFuIFMwHhcNMjMwODI0MTgzMTM2WhcNMjQwODIzMTgzMTM2WjBZMQswCQYDVQQGEwJURTENMAsGA1UECBMEVGVzdDENMAsGA1UEBxMEVGVzdDENMAsGA1UEChMEVGVzdDENMAsGA1UECxMEVGVzdDEOMAwGA1UEAxMFSmFuIFMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCEO5+UBxpzSI5vtmPvzlyz0d1yHph33VG9bFkqQ7bnmE3ghHiFyGTBrD7IPTrBg1NjhdC5jqpa/acY/oWUAwV5dlyD02nVBoy7VBLvz7QOc9QPaUQ2frVY4Qy05qgLZEGgnbpG2X44sqV8ogW1y2IaY+2F9NBKralp8r6M/sB4DKJmTWd+1UOlpnlfr3H5KvX+YXzfC+KVDIEoG7yzXZuw7I3x0VawVz/5gx3FAkagi7yHh9J58kmImpOFquxcK6SdMCoBsWmFmHmCpExVfxrVLMpIV5a5AHDWn84YHHRKC5kvhnyujXaIHFV+78TFxk8DGnPnKcgFRWKRVyfcYT7hAgMBAAGjITAfMB0GA1UdDgQWBBQFR8qbfk35MQdMqxQhZ2kkLoLPDDANBgkqhkiG9w0BAQsFAAOCAQEAI6aAn6zKcRieUkyHraRswYhxRjpc1fDaeDz01XanqxUIpNf31dU+f62oo5Dv4VAgd6MzLdFSpcERB9ScJzIIrz7mnqS0r8LhesDejs/mnDg2+E89XHy7yZtU3MqAXmESe7qms8AB1F68YZ+OOjWfn1AZfjaAzU0La49NaFbQE96vBLBGNX2Efavk7trv9PMCKMio19w/FBGwH8cU0erAi/WZhRlX3C1eFfmGSs5BWZL03yOmdeR/PVNut135dbk0EJ9rn6TJp1KgoNqcnsbPZcSxmUB/3U/hWUoFb6UN7g+1NbVh0PHCZ2Kbwyk6mO2viNbXka1WuopUxGh1RmJW9A==

  • Private key in Base64: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCEO5+UBxpzSI5vtmPvzlyz0d1yHph33VG9bFkqQ7bnmE3ghHiFyGTBrD7IPTrBg1NjhdC5jqpa/acY/oWUAwV5dlyD02nVBoy7VBLvz7QOc9QPaUQ2frVY4Qy05qgLZEGgnbpG2X44sqV8ogW1y2IaY+2F9NBKralp8r6M/sB4DKJmTWd+1UOlpnlfr3H5KvX+YXzfC+KVDIEoG7yzXZuw7I3x0VawVz/5gx3FAkagi7yHh9J58kmImpOFquxcK6SdMCoBsWmFmHmCpExVfxrVLMpIV5a5AHDWn84YHHRKC5kvhnyujXaIHFV+78TFxk8DGnPnKcgFRWKRVyfcYT7hAgMBAAECggEAdp8iBWHl6XsyQ6bDufFOmgVu+RvXPNfuptXWmyKJpvKrEfjkQWdGc7L30xuSZNxRZxs45ezEh8G6L6LL475eH9r9HUj/TJmGj9nY7wZNiRWBK54MEjLSrfudMX8lSqrScKpt23bqUyR3bfnO04my5Oe1wRCf9g4ZxzB6nfM+Z7Hq+HxJRk5KlkMCGCf5Q0kA0t2FCkEHyvLUKft/ba05/XNODnDbA3n7AkWCzKmU9ypRen2OjqWaamQAaD0eJvxXDMEsAbZ+xGzD68GKs9+glmF9iZUoDwFMa3aCqVTpskOUtYxKHXVwgbhZoPpgmC+NPjlwaWdntp8a77q/Z0Jg0QKBgQDEwxbwvUHG3vtUK27WMBW7UunwUHuyA5ECaZG6Spm1rq6OhGLaSOWDNUF9VVgLTEkW6M/stxEuTqzWT1lbUqGIYeOkP6e5tD9LIYoUb6Sozpa6f0cwEsxNKpVOxRIn9tIOx3dZS164+rT1d75+t6W9tHPGFaOZJOKwObn9eoJK7QKBgQCsCx7xQq+rawXoNPQBBABSvLe4alxCQ8MhBDhFYjWAh9scfocCDFHSYXzG1AhMG5dBMNWvkL3xuXVw1ba7FltSb1VHvjQqkr9AV+QNR4sCorVy1tNIuH63Jwtsn3UkYr0CUU6u/sWZW5pirs+fobFv2DZraPnB3j5z1d8RpjShRQKBgF3ilMSkGYmyBgxgeQ98fDIY2wVO8ea76upSwzU3uWZGhoX8R0rOs6zKsYgDO/KQIOPsjKHvrCQDaFcOH54CrI7t3ngV44sppXXM+BzONKxTfvpYFviqT4+WfQ3L3ODy1cI1jQ4vd3AeOFBUJbJDILOHMiLXWmuNfRkHQmbfmOH1AoGAFilQkQ9gBZrBpgm8LK1RRVcd61l4DOkhp40dmoJuFeJqLR93UKI5n/oC0rHZZ8ReFX2u6PCiJxMWt7Qv16WnmdTRjW5I1fsVO7qWm8dNdsdyzBo0GTf6yqjy5ckck9VMN5I1qoES/xA3sOKHyC5R5vBZAjkBgyGXteAk3eck/GkCgYEArm4brBpXj+Ox6Korw0Hk2bdk30R7BAm2+N+E1683wJBnWP3kiGGRDaGulUtoq2HthXr42ZyfwtuiS9UrAJFOWDLy3UYxeR1DYDFoq+mdy1fR8IniWXyGZQ6goJ0t/SNIN1NsRnh03Fc8iqRqYEs3anvuOr8xGvMsxXW0qT9OLcY=

jan_s
  • 1
  • 1
  • 1
    *"I know that we would get the best help if we shared the PDF before and after signing"* - indeed, please share. – mkl Aug 24 '23 at 13:52
  • @KJ yes I tried that version as well, here's the code: https://gist.github.com/jan-sz/7c2fdf1e48e302bca01711440d364316 Validation in Adobe Acrobat Reader shows "Certification is invalid" "Document has been altered or corrupted since it was signed". When I checked the size of `ByteArrayOutputStream` I return in `ContentSigner.getOutputStream` it's `baos.toByteArray().length` is 0. – jan_s Aug 24 '23 at 17:24
  • @mkl I edited my question - I added a class that imitiates the external signature and added the examples of PDFs and keys used for signature – – jan_s Aug 25 '23 at 08:36

1 Answers1

0

Analyzing your example file one sees that none of the hashes in the signature is correct. Furthermore, one sees that the CMS signature container is not of the simple form required for your hack to work.

Creating a simple signature container (no signed attributes)

You desire to create a simple signature container (no signed attributes, the signature bytes directly sign the data) for your ContentSigner hack to work.

Your example file contains a signature container with signed attributes, though. Thus, both the hash in the signed attributes and in the signature bytes are wrong, the former because you use "not used".getBytes() as generator input and the latter because it is not based on the signed attributes.

The cause is that you incompletely copied the code from the answer you used as template: you dropped the call of the JcaSignerInfoGeneratorBuilder method setDirectSignature there:

    JcaSignerInfoGeneratorBuilder sigb = new JcaSignerInfoGeneratorBuilder(new JcaDigestCalculatorProviderBuilder().build());
    sigb.setDirectSignature(true);

But the JcaSignerInfoGeneratorBuilder only creates simple signature container structures if DirectSignature is set to true.

Thus, please complete your copy of that code.

Signing the correct hash

Even after switching to creation of simple signature containers you'll sign the wrong hash because your flow with the ExternalSignature test replacement after hashing the data using MessageDigest.getInstance("SHA256", new BouncyCastleProvider()) at the beginning of CreateSignature.sign hashes that hash again by using Signature.getInstance("SHA256withRSA") in ExternalSignature.sign.

To make that flow work, you'll have to remove one of the hash calculations.

I don't know, though, whether your ExternalSignature test replacement really correctly represents your ServerSignature functionality. If the latter does not hash itself but truly expects pre-hashed data, your flow with it may work without further ado after switching to creation of simple signature containers.

As an aside

Why do you insist on creating simple signature containers? Every signature profile that is somehow to be taken seriously requires the use of signed attributes.

mkl
  • 90,588
  • 15
  • 125
  • 265
  • thank you for that answer, indeed we missed `sigb.setDirectSignature(true);` - after adding this the Adobe Acrobat doesn't say that Document was altered anymore (there are some other problems most probably caused by what you explained about hashes). – jan_s Aug 28 '23 at 16:54
  • Up until now I wasn't aware that there are different ways to sign the PDF, thank you for that, I found the explanation that helps me understand it a bit better here: https://eideasy.com/how-hash-digest-signing-works-for-pdf-pades/ - we were trying to create a simple signature container because we didn't know any better. Our desire is to calculate the hash that takes into consideration the public key from the certificate. – jan_s Aug 28 '23 at 17:02
  • I actually thought that by using `document.addSignature(signature); ExternalSigningSupport externalSigning = document.saveIncrementalForExternalSigning(output); sign(externalSigning.getContent());` we would be able to add the signer's public key and calculate the hash based on that but it just shows my lack of understanding of these libraries and I couldn't find any tutorials for PDFBox and signature - could you please help me fix it ? From other posts here I understood that this hack with `ContentSigner nonSigner_signedHashProvided` is required if some API signs the hash and returns it for us – jan_s Aug 28 '23 at 17:08