diff --git a/README.md b/README.md index 8c50fdc6b..9c035c92a 100644 --- a/README.md +++ b/README.md @@ -85,13 +85,13 @@ Hosted instance/demo of the app can be seen [here](https://pdf.adminforge.de/) h ## Technologies used - Spring Boot + Thymeleaf -- PDFBox +- [PDFBox](https://github.com/apache/pdfbox/tree/trunk) - [LibreOffice](https://www.libreoffice.org/discover/libreoffice/) for advanced conversions - [OcrMyPdf](https://github.com/ocrmypdf/OCRmyPDF) - HTML, CSS, JavaScript - Docker -- PDF.js -- PDF-LIB.js +- [PDF.js](https://github.com/mozilla/pdf.js) +- [PDF-LIB.js](https://github.com/Hopding/pdf-lib) ## How to use diff --git a/src/main/java/org/apache/pdfbox/examples/signature/CMSProcessableInputStream.java b/src/main/java/org/apache/pdfbox/examples/signature/CMSProcessableInputStream.java new file mode 100644 index 000000000..48d1da980 --- /dev/null +++ b/src/main/java/org/apache/pdfbox/examples/signature/CMSProcessableInputStream.java @@ -0,0 +1,64 @@ +/* + * 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; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.cms.CMSObjectIdentifiers; +import org.bouncycastle.cms.CMSException; +import org.bouncycastle.cms.CMSTypedData; + +/** + * Wraps a InputStream into a CMSProcessable object for bouncy castle. It's a memory saving + * alternative to the {@link org.bouncycastle.cms.CMSProcessableByteArray CMSProcessableByteArray} + * class. + * + * @author Thomas Chojecki + */ +class CMSProcessableInputStream implements CMSTypedData { + private final InputStream in; + private final ASN1ObjectIdentifier contentType; + + CMSProcessableInputStream(InputStream is) { + this(new ASN1ObjectIdentifier(CMSObjectIdentifiers.data.getId()), is); + } + + CMSProcessableInputStream(ASN1ObjectIdentifier type, InputStream is) { + contentType = type; + in = is; + } + + @Override + public Object getContent() { + return in; + } + + @Override + public void write(OutputStream out) throws IOException, CMSException { + // read the content only one time + in.transferTo(out); + in.close(); + } + + @Override + public ASN1ObjectIdentifier getContentType() { + return contentType; + } +} diff --git a/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java b/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java new file mode 100644 index 000000000..646561f0d --- /dev/null +++ b/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java @@ -0,0 +1,170 @@ +/* + * Copyright 2015 The Apache Software Foundation. + * + * 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 org.apache.pdfbox.examples.signature; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Enumeration; + +import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface; +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.jcajce.JcaSignerInfoGeneratorBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; + +public abstract class CreateSignatureBase implements SignatureInterface { + private PrivateKey privateKey; + private Certificate[] certificateChain; + private String tsaUrl; + private boolean externalSigning; + + /** + * Initialize the signature creator with a keystore (pkcs12) and pin that should be used for the + * signature. + * + * @param keystore is a pkcs12 keystore. + * @param pin is the pin for the keystore / private key + * @throws KeyStoreException if the keystore has not been initialized (loaded) + * @throws NoSuchAlgorithmException if the algorithm for recovering the key cannot be found + * @throws UnrecoverableKeyException if the given password is wrong + * @throws CertificateException if the certificate is not valid as signing time + * @throws IOException if no certificate could be found + */ + public CreateSignatureBase(KeyStore keystore, char[] pin) + throws KeyStoreException, + UnrecoverableKeyException, + NoSuchAlgorithmException, + IOException, + CertificateException { + // 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 aliases = keystore.aliases(); + String alias; + Certificate cert = null; + while (cert == null && aliases.hasMoreElements()) { + alias = aliases.nextElement(); + setPrivateKey((PrivateKey) keystore.getKey(alias, pin)); + 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"); + } + } + + public final void setPrivateKey(PrivateKey privateKey) { + this.privateKey = privateKey; + } + + public final void setCertificateChain(final Certificate[] certificateChain) { + this.certificateChain = certificateChain; + } + + public Certificate[] getCertificateChain() { + return certificateChain; + } + + public void setTsaUrl(String tsaUrl) { + this.tsaUrl = tsaUrl; + } + + /** + * SignatureInterface sample implementation. + * + *

This method will be called from inside of the pdfbox and create the PKCS #7 signature. The + * given InputStream contains the bytes that are given by the byte range. + * + *

This method is for internal use only. + * + *

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 this + * answer or this answer. + * + * @throws IOException + */ + @Override + public byte[] sign(InputStream content) throws IOException { + // cannot be done private (interface) + try { + CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); + X509Certificate cert = (X509Certificate) certificateChain[0]; + ContentSigner sha1Signer = + new JcaContentSignerBuilder("SHA256WithRSA").build(privateKey); + gen.addSignerInfoGenerator( + new JcaSignerInfoGeneratorBuilder( + new JcaDigestCalculatorProviderBuilder().build()) + .build(sha1Signer, cert)); + gen.addCertificates(new JcaCertStore(Arrays.asList(certificateChain))); + CMSProcessableInputStream msg = new CMSProcessableInputStream(content); + CMSSignedData signedData = gen.generate(msg, false); + if (tsaUrl != null && !tsaUrl.isEmpty()) { + ValidationTimeStamp validation = new ValidationTimeStamp(tsaUrl); + signedData = validation.addSignedTimeStamp(signedData); + } + return signedData.getEncoded(); + } catch (GeneralSecurityException + | CMSException + | OperatorCreationException + | URISyntaxException e) { + throw new IOException(e); + } + } + + /** + * Set if external signing scenario should be used. If {@code false}, SignatureInterface would + * be used for signing. + * + *

Default: {@code false} + * + * @param externalSigning {@code true} if external signing should be performed + */ + public void setExternalSigning(boolean externalSigning) { + this.externalSigning = externalSigning; + } + + public boolean isExternalSigning() { + return externalSigning; + } +} diff --git a/src/main/java/org/apache/pdfbox/examples/signature/TSAClient.java b/src/main/java/org/apache/pdfbox/examples/signature/TSAClient.java new file mode 100644 index 000000000..a215fe525 --- /dev/null +++ b/src/main/java/org/apache/pdfbox/examples/signature/TSAClient.java @@ -0,0 +1,176 @@ +/* + * 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; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.math.BigInteger; +import java.net.URL; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Random; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.operator.DefaultDigestAlgorithmIdentifierFinder; +import org.bouncycastle.operator.DigestAlgorithmIdentifierFinder; +import org.bouncycastle.tsp.TSPException; +import org.bouncycastle.tsp.TimeStampRequest; +import org.bouncycastle.tsp.TimeStampRequestGenerator; +import org.bouncycastle.tsp.TimeStampResponse; +import org.bouncycastle.tsp.TimeStampToken; + +/** + * Time Stamping Authority (TSA) Client [RFC 3161]. + * + * @author Vakhtang Koroghlishvili + * @author John Hewson + */ +public class TSAClient { + private static final Logger LOG = LogManager.getLogger(TSAClient.class); + + private static final DigestAlgorithmIdentifierFinder ALGORITHM_OID_FINDER = + new DefaultDigestAlgorithmIdentifierFinder(); + + private final URL url; + private final String username; + private final String password; + private final MessageDigest digest; + + // SecureRandom.getInstanceStrong() would be better, but sometimes blocks on Linux + private static final Random RANDOM = new SecureRandom(); + + /** + * @param url the URL of the TSA service + * @param username user name of TSA + * @param password password of TSA + * @param digest the message digest to use + */ + public TSAClient(URL url, String username, String password, MessageDigest digest) { + this.url = url; + this.username = username; + this.password = password; + this.digest = digest; + } + + /** + * @param content + * @return the time stamp token + * @throws IOException if there was an error with the connection or data from the TSA server, or + * if the time stamp response could not be validated + */ + public TimeStampToken getTimeStampToken(InputStream content) throws IOException { + digest.reset(); + DigestInputStream dis = new DigestInputStream(content, digest); + while (dis.read() != -1) { + // do nothing + } + byte[] hash = digest.digest(); + + // 32-bit cryptographic nonce + int nonce = RANDOM.nextInt(); + + // generate TSA request + TimeStampRequestGenerator tsaGenerator = new TimeStampRequestGenerator(); + tsaGenerator.setCertReq(true); + ASN1ObjectIdentifier oid = ALGORITHM_OID_FINDER.find(digest.getAlgorithm()).getAlgorithm(); + TimeStampRequest request = tsaGenerator.generate(oid, hash, BigInteger.valueOf(nonce)); + + // get TSA response + byte[] tsaResponse = getTSAResponse(request.getEncoded()); + + TimeStampResponse response; + try { + response = new TimeStampResponse(tsaResponse); + response.validate(request); + } catch (TSPException e) { + throw new IOException(e); + } + + TimeStampToken timeStampToken = response.getTimeStampToken(); + if (timeStampToken == null) { + // https://www.ietf.org/rfc/rfc3161.html#section-2.4.2 + throw new IOException( + "Response from " + + url + + " does not have a time stamp token, status: " + + response.getStatus() + + " (" + + response.getStatusString() + + ")"); + } + + return timeStampToken; + } + + // gets response data for the given encoded TimeStampRequest data + // throws IOException if a connection to the TSA cannot be established + private byte[] getTSAResponse(byte[] request) throws IOException { + LOG.debug("Opening connection to TSA server"); + + // todo: support proxy servers + URLConnection connection = url.openConnection(); + connection.setDoOutput(true); + connection.setDoInput(true); + connection.setRequestProperty("Content-Type", "application/timestamp-query"); + + LOG.debug("Established connection to TSA server"); + + if (username != null && password != null && !username.isEmpty() && !password.isEmpty()) { + String contentEncoding = connection.getContentEncoding(); + if (contentEncoding == null) { + contentEncoding = StandardCharsets.UTF_8.name(); + } + connection.setRequestProperty( + "Authorization", + "Basic " + + new String( + Base64.getEncoder() + .encode( + (username + ":" + password) + .getBytes(contentEncoding)))); + } + + // read response + try (OutputStream output = connection.getOutputStream()) { + output.write(request); + } catch (IOException ex) { + LOG.error("Exception when writing to {}", this.url, ex); + throw ex; + } + + LOG.debug("Waiting for response from TSA server"); + + byte[] response; + try (InputStream input = connection.getInputStream()) { + response = input.readAllBytes(); + } catch (IOException ex) { + LOG.error("Exception when reading from {}", this.url, ex); + throw ex; + } + + LOG.debug("Received response from TSA server"); + + return response; + } +} diff --git a/src/main/java/org/apache/pdfbox/examples/signature/ValidationTimeStamp.java b/src/main/java/org/apache/pdfbox/examples/signature/ValidationTimeStamp.java new file mode 100644 index 000000000..d01666d76 --- /dev/null +++ b/src/main/java/org/apache/pdfbox/examples/signature/ValidationTimeStamp.java @@ -0,0 +1,134 @@ +/* + * 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; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; + +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1EncodableVector; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.ASN1Primitive; +import org.bouncycastle.asn1.DERSet; +import org.bouncycastle.asn1.cms.Attribute; +import org.bouncycastle.asn1.cms.AttributeTable; +import org.bouncycastle.asn1.cms.Attributes; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.cms.CMSSignedData; +import org.bouncycastle.cms.SignerInformation; +import org.bouncycastle.cms.SignerInformationStore; +import org.bouncycastle.tsp.TimeStampToken; + +/** + * This class wraps the TSAClient and the work that has to be done with it. Like Adding Signed + * TimeStamps to a signature, or creating a CMS timestamp attribute (with a signed timestamp) + * + * @author Others + * @author Alexis Suter + */ +public class ValidationTimeStamp { + private TSAClient tsaClient; + + /** + * @param tsaUrl The url where TS-Request will be done. + * @throws NoSuchAlgorithmException + * @throws MalformedURLException + * @throws java.net.URISyntaxException + */ + public ValidationTimeStamp(String tsaUrl) + throws NoSuchAlgorithmException, MalformedURLException, URISyntaxException { + if (tsaUrl != null) { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + this.tsaClient = new TSAClient(new URI(tsaUrl).toURL(), null, null, digest); + } + } + + /** + * Creates a signed timestamp token by the given input stream. + * + * @param content InputStream of the content to sign + * @return the byte[] of the timestamp token + * @throws IOException + */ + public byte[] getTimeStampToken(InputStream content) throws IOException { + TimeStampToken timeStampToken = tsaClient.getTimeStampToken(content); + return timeStampToken.getEncoded(); + } + + /** + * Extend cms signed data with TimeStamp first or to all signers + * + * @param signedData Generated CMS signed data + * @return CMSSignedData Extended CMS signed data + * @throws IOException + */ + public CMSSignedData addSignedTimeStamp(CMSSignedData signedData) throws IOException { + SignerInformationStore signerStore = signedData.getSignerInfos(); + List newSigners = new ArrayList<>(); + + for (SignerInformation signer : signerStore.getSigners()) { + // This adds a timestamp to every signer (into his unsigned attributes) in the + // signature. + newSigners.add(signTimeStamp(signer)); + } + + // Because new SignerInformation is created, new SignerInfoStore has to be created + // and also be replaced in signedData. Which creates a new signedData object. + return CMSSignedData.replaceSigners(signedData, new SignerInformationStore(newSigners)); + } + + /** + * Extend CMS Signer Information with the TimeStampToken into the unsigned Attributes. + * + * @param signer information about signer + * @return information about SignerInformation + * @throws IOException + */ + private SignerInformation signTimeStamp(SignerInformation signer) throws IOException { + AttributeTable unsignedAttributes = signer.getUnsignedAttributes(); + + ASN1EncodableVector vector = new ASN1EncodableVector(); + if (unsignedAttributes != null) { + vector = unsignedAttributes.toASN1EncodableVector(); + } + + TimeStampToken timeStampToken = + tsaClient.getTimeStampToken(new ByteArrayInputStream(signer.getSignature())); + byte[] token = timeStampToken.getEncoded(); + ASN1ObjectIdentifier oid = PKCSObjectIdentifiers.id_aa_signatureTimeStampToken; + ASN1Encodable signatureTimeStamp = + new Attribute(oid, new DERSet(ASN1Primitive.fromByteArray(token))); + + vector.add(signatureTimeStamp); + Attributes signedAttributes = new Attributes(vector); + + // There is no other way changing the unsigned attributes of the signer information. + // result is never null, new SignerInformation always returned, + // see source code of replaceUnsignedAttributes + return SignerInformation.replaceUnsignedAttributes( + signer, new AttributeTable(signedAttributes)); + } +} diff --git a/src/main/java/org/apache/pdfbox/examples/util/ConnectedInputStream.java b/src/main/java/org/apache/pdfbox/examples/util/ConnectedInputStream.java new file mode 100644 index 000000000..30434f716 --- /dev/null +++ b/src/main/java/org/apache/pdfbox/examples/util/ConnectedInputStream.java @@ -0,0 +1,82 @@ +/* + * 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.util; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; + +/** + * Delegate class to close the connection when the class gets closed. + * + * @author Tilman Hausherr + */ +public class ConnectedInputStream extends InputStream { + HttpURLConnection con; + InputStream is; + + public ConnectedInputStream(HttpURLConnection con, InputStream is) { + this.con = con; + this.is = is; + } + + @Override + public int read() throws IOException { + return is.read(); + } + + @Override + public int read(byte[] b) throws IOException { + return is.read(b); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + return is.read(b, off, len); + } + + @Override + public long skip(long n) throws IOException { + return is.skip(n); + } + + @Override + public int available() throws IOException { + return is.available(); + } + + @Override + public synchronized void mark(int readlimit) { + is.mark(readlimit); + } + + @Override + public synchronized void reset() throws IOException { + is.reset(); + } + + @Override + public boolean markSupported() { + return is.markSupported(); + } + + @Override + public void close() throws IOException { + is.close(); + con.disconnect(); + } +} diff --git a/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java b/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java index 8990c789d..1ead1a97b 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java @@ -4,44 +4,34 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStreamReader; -import java.security.KeyFactory; +import java.io.OutputStream; import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.Security; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.security.spec.PKCS8EncodedKeySpec; -import java.text.SimpleDateFormat; import java.util.Calendar; -import java.util.Collections; -import java.util.Date; -import org.apache.commons.io.IOUtils; +import org.apache.pdfbox.examples.signature.CreateSignatureBase; import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDPage; -import org.apache.pdfbox.pdmodel.PDPageContentStream; -import org.apache.pdfbox.pdmodel.PDResources; -import org.apache.pdfbox.pdmodel.common.PDRectangle; -import org.apache.pdfbox.pdmodel.font.PDType1Font; -import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; -import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; -import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream; -import org.apache.pdfbox.pdmodel.interactive.digitalsignature.ExternalSigningSupport; import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature; -import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureOptions; -import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; -import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; -import org.bouncycastle.cert.jcajce.JcaCertStore; -import org.bouncycastle.cms.CMSProcessableByteArray; -import org.bouncycastle.cms.CMSSignedData; -import org.bouncycastle.cms.CMSSignedDataGenerator; -import org.bouncycastle.cms.CMSTypedData; -import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.bouncycastle.operator.ContentSigner; -import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; -import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; -import org.bouncycastle.util.io.pem.PemReader; +import org.bouncycastle.openssl.PEMDecryptorProvider; +import org.bouncycastle.openssl.PEMEncryptedKeyPair; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder; +import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder; +import org.bouncycastle.operator.InputDecryptorProvider; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo; +import org.bouncycastle.pkcs.PKCSException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; @@ -68,6 +58,17 @@ public class CertSignController { Security.addProvider(new BouncyCastleProvider()); } + class CreateSignature extends CreateSignatureBase { + public CreateSignature(KeyStore keystore, char[] pin) + throws KeyStoreException, + UnrecoverableKeyException, + NoSuchAlgorithmException, + IOException, + CertificateException { + super(keystore, pin); + } + } + @PostMapping(consumes = "multipart/form-data", value = "/cert-sign") @Operation( summary = "Sign PDF with a Digital Certificate", @@ -80,6 +81,7 @@ public class CertSignController { MultipartFile privateKeyFile = request.getPrivateKeyFile(); MultipartFile certFile = request.getCertFile(); MultipartFile p12File = request.getP12File(); + MultipartFile jksfile = request.getJksFile(); String password = request.getPassword(); Boolean showSignature = request.isShowSignature(); String reason = request.getReason(); @@ -87,203 +89,94 @@ public class CertSignController { String name = request.getName(); Integer pageNumber = request.getPageNumber(); - PrivateKey privateKey = null; - X509Certificate cert = null; - - if (certType != null) { - logger.info("Cert type provided: {}", certType); - switch (certType) { - case "PKCS12": - if (p12File != null) { - KeyStore ks = KeyStore.getInstance("PKCS12"); - ks.load( - new ByteArrayInputStream(p12File.getBytes()), - password.toCharArray()); - String alias = ks.aliases().nextElement(); - if (!ks.isKeyEntry(alias)) { - throw new IllegalArgumentException( - "The provided PKCS12 file does not contain a private key."); - } - privateKey = (PrivateKey) ks.getKey(alias, password.toCharArray()); - cert = (X509Certificate) ks.getCertificate(alias); - } - break; - case "PEM": - if (privateKeyFile != null && certFile != null) { - // Load private key - KeyFactory keyFactory = - KeyFactory.getInstance("RSA", BouncyCastleProvider.PROVIDER_NAME); - if (isPEM(privateKeyFile.getBytes())) { - privateKey = - keyFactory.generatePrivate( - new PKCS8EncodedKeySpec( - parsePEM(privateKeyFile.getBytes()))); - } else { - privateKey = - keyFactory.generatePrivate( - new PKCS8EncodedKeySpec(privateKeyFile.getBytes())); - } - - // Load certificate - CertificateFactory certFactory = - CertificateFactory.getInstance( - "X.509", BouncyCastleProvider.PROVIDER_NAME); - if (isPEM(certFile.getBytes())) { - cert = - (X509Certificate) - certFactory.generateCertificate( - new ByteArrayInputStream( - parsePEM(certFile.getBytes()))); - } else { - cert = - (X509Certificate) - certFactory.generateCertificate( - new ByteArrayInputStream(certFile.getBytes())); - } - } - break; - } + if (certType == null) { + throw new IllegalArgumentException("Cert type must be provided"); } - PDSignature signature = new PDSignature(); - signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE); // default filter - signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_SHA1); - signature.setName(name); - signature.setLocation(location); - signature.setReason(reason); - signature.setSignDate(Calendar.getInstance()); - // Load the PDF - try (PDDocument document = PDDocument.load(pdf.getBytes())) { - logger.info("Successfully loaded the provided PDF"); - SignatureOptions signatureOptions = new SignatureOptions(); + KeyStore ks = null; - // If you want to show the signature + switch (certType) { + case "PEM": + ks = KeyStore.getInstance("JKS"); + ks.load(null); + PrivateKey privateKey = getPrivateKeyFromPEM(privateKeyFile.getBytes(), password); + Certificate cert = (Certificate) getCertificateFromPEM(certFile.getBytes()); + ks.setKeyEntry( + "alias", privateKey, password.toCharArray(), new Certificate[] {cert}); + break; + case "PKCS12": + ks = KeyStore.getInstance("PKCS12"); + ks.load(p12File.getInputStream(), password.toCharArray()); + break; + case "JKS": + ks = KeyStore.getInstance("JKS"); + ks.load(jksfile.getInputStream(), password.toCharArray()); + break; + default: + throw new IllegalArgumentException("Invalid cert type: " + certType); + } - // ATTEMPT 2 - if (showSignature != null && showSignature) { - PDPage page = document.getPage(pageNumber - 1); + // TODO: page number - PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm(); - if (acroForm == null) { - acroForm = new PDAcroForm(document); - document.getDocumentCatalog().setAcroForm(acroForm); - } + CreateSignature createSignature = new CreateSignature(ks, password.toCharArray()); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + sign(pdf.getBytes(), baos, createSignature, name, location, reason); + return WebResponseUtils.boasToWebResponse( + baos, pdf.getOriginalFilename().replaceFirst("[.][^.]+$", "") + "_signed.pdf"); + } - // Create a new signature field and widget + private static void sign( + byte[] input, + OutputStream output, + CreateSignature instance, + String name, + String location, + String reason) { + try (PDDocument doc = PDDocument.load(input)) { + PDSignature signature = new PDSignature(); + signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE); + signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED); + signature.setName(name); + signature.setLocation(location); + signature.setReason(reason); + signature.setSignDate(Calendar.getInstance()); - PDSignatureField signatureField = new PDSignatureField(acroForm); - PDAnnotationWidget widget = signatureField.getWidgets().get(0); - PDRectangle rect = - new PDRectangle(100, 100, 200, 50); // Define the rectangle size here - widget.setRectangle(rect); - page.getAnnotations().add(widget); - - // Set the appearance for the signature field - PDAppearanceDictionary appearanceDict = new PDAppearanceDictionary(); - PDAppearanceStream appearanceStream = new PDAppearanceStream(document); - appearanceStream.setResources(new PDResources()); - appearanceStream.setBBox(rect); - appearanceDict.setNormalAppearance(appearanceStream); - widget.setAppearance(appearanceDict); - - try (PDPageContentStream contentStream = - new PDPageContentStream(document, appearanceStream)) { - contentStream.beginText(); - contentStream.setFont(PDType1Font.HELVETICA_BOLD, 12); - contentStream.newLineAtOffset(110, 130); - contentStream.showText( - "Digitally signed by: " + (name != null ? name : "Unknown")); - contentStream.newLineAtOffset(0, -15); - contentStream.showText( - "Date: " - + new SimpleDateFormat("yyyy.MM.dd HH:mm:ss z") - .format(new Date())); - contentStream.newLineAtOffset(0, -15); - if (reason != null && !reason.isEmpty()) { - contentStream.showText("Reason: " + reason); - contentStream.newLineAtOffset(0, -15); - } - if (location != null && !location.isEmpty()) { - contentStream.showText("Location: " + location); - contentStream.newLineAtOffset(0, -15); - } - contentStream.endText(); - } - - // Add the widget annotation to the page - page.getAnnotations().add(widget); - - // Add the signature field to the acroform - acroForm.getFields().add(signatureField); - - // Handle multiple signatures by ensuring a unique field name - String baseFieldName = "Signature"; - String signatureFieldName = baseFieldName; - int suffix = 1; - while (acroForm.getField(signatureFieldName) != null) { - suffix++; - signatureFieldName = baseFieldName + suffix; - } - signatureField.setPartialName(signatureFieldName); - } - - document.addSignature(signature, signatureOptions); - logger.info("Signature added to the PDF document"); - // External signing - ExternalSigningSupport externalSigning = - document.saveIncrementalForExternalSigning(new ByteArrayOutputStream()); - - byte[] content = IOUtils.toByteArray(externalSigning.getContent()); - - // Using BouncyCastle to sign - CMSTypedData cmsData = new CMSProcessableByteArray(content); - - CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); - ContentSigner signer = - new JcaContentSignerBuilder("SHA256withRSA") - .setProvider(BouncyCastleProvider.PROVIDER_NAME) - .build(privateKey); - - gen.addSignerInfoGenerator( - new JcaSignerInfoGeneratorBuilder( - new JcaDigestCalculatorProviderBuilder() - .setProvider(BouncyCastleProvider.PROVIDER_NAME) - .build()) - .build(signer, cert)); - - gen.addCertificates(new JcaCertStore(Collections.singletonList(cert))); - CMSSignedData signedData = gen.generate(cmsData, false); - - byte[] cmsSignature = signedData.getEncoded(); - logger.info("About to sign content using BouncyCastle"); - externalSigning.setSignature(cmsSignature); - logger.info("Signature set successfully"); - - // After setting the signature, return the resultant PDF - try (ByteArrayOutputStream signedPdfOutput = new ByteArrayOutputStream()) { - document.save(signedPdfOutput); - return WebResponseUtils.boasToWebResponse( - signedPdfOutput, - pdf.getOriginalFilename().replaceFirst("[.][^.]+$", "") + "_signed.pdf"); - - } catch (Exception e) { - e.printStackTrace(); - } + doc.addSignature(signature, instance); + doc.saveIncremental(output); } catch (Exception e) { e.printStackTrace(); } - - return null; } - private byte[] parsePEM(byte[] content) throws IOException { - PemReader pemReader = - new PemReader(new InputStreamReader(new ByteArrayInputStream(content))); - return pemReader.readPemObject().getContent(); + private PrivateKey getPrivateKeyFromPEM(byte[] pemBytes, String password) + throws IOException, OperatorCreationException, PKCSException { + try (PEMParser pemParser = + new PEMParser(new InputStreamReader(new ByteArrayInputStream(pemBytes)))) { + Object pemObject = pemParser.readObject(); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC"); + PrivateKeyInfo pkInfo; + if (pemObject instanceof PKCS8EncryptedPrivateKeyInfo) { + InputDecryptorProvider decProv = + new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password.toCharArray()); + pkInfo = ((PKCS8EncryptedPrivateKeyInfo) pemObject).decryptPrivateKeyInfo(decProv); + } else if (pemObject instanceof PEMEncryptedKeyPair) { + PEMDecryptorProvider decProv = + new JcePEMDecryptorProviderBuilder().build(password.toCharArray()); + pkInfo = + ((PEMEncryptedKeyPair) pemObject) + .decryptKeyPair(decProv) + .getPrivateKeyInfo(); + } else { + pkInfo = ((PEMKeyPair) pemObject).getPrivateKeyInfo(); + } + return converter.getPrivateKey(pkInfo); + } } - private boolean isPEM(byte[] content) { - String contentStr = new String(content); - return contentStr.contains("-----BEGIN") && contentStr.contains("-----END"); + private Certificate getCertificateFromPEM(byte[] pemBytes) + throws IOException, CertificateException { + try (ByteArrayInputStream bis = new ByteArrayInputStream(pemBytes)) { + return CertificateFactory.getInstance("X.509").generateCertificate(bis); + } } } diff --git a/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java b/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java index a1fc2fcee..d3399db9d 100644 --- a/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java +++ b/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java @@ -14,7 +14,7 @@ public class SignPDFWithCertRequest extends PDFFile { @Schema( description = "The type of the digital certificate", - allowableValues = {"PKCS12", "PEM"}) + allowableValues = {"PEM", "PKCS12", "JKS"}) private String certType; @Schema( @@ -28,6 +28,9 @@ public class SignPDFWithCertRequest extends PDFFile { @Schema(description = "The PKCS12 keystore file (required for PKCS12 type certificates)") private MultipartFile p12File; + @Schema(description = "The JKS keystore file (Java Key Store)") + private MultipartFile jksFile; + @Schema(description = "The password for the keystore or the private key") private String password; diff --git a/src/main/resources/messages_en_GB.properties b/src/main/resources/messages_en_GB.properties index 6cba440e8..235bf07b0 100644 --- a/src/main/resources/messages_en_GB.properties +++ b/src/main/resources/messages_en_GB.properties @@ -546,9 +546,11 @@ scalePages.submit=Submit certSign.title=Certificate Signing certSign.header=Sign a PDF with your certificate (Work in progress) certSign.selectPDF=Select a PDF File for Signing: +certSign.jksNote=Note: If your certificate type is not listed below, please convert it to a Java Keystore (.jks) file using the keytool command line tool. Then, choose the .jks file option below. certSign.selectKey=Select Your Private Key File (PKCS#8 format, could be .pem or .der): certSign.selectCert=Select Your Certificate File (X.509 format, could be .pem or .der): certSign.selectP12=Select Your PKCS#12 Keystore File (.p12 or .pfx) (Optional, If provided, it should contain your private key and certificate): +certSign.selectJKS=Select Your Java Keystore File (.jks or .keystore): certSign.certType=Certificate Type certSign.password=Enter Your Keystore or Private Key Password (If Any): certSign.showSig=Show Signature diff --git a/src/main/resources/messages_en_US.properties b/src/main/resources/messages_en_US.properties index 9b7efd3a1..0c6572da9 100644 --- a/src/main/resources/messages_en_US.properties +++ b/src/main/resources/messages_en_US.properties @@ -546,9 +546,11 @@ scalePages.submit=Submit certSign.title=Certificate Signing certSign.header=Sign a PDF with your certificate (Work in progress) certSign.selectPDF=Select a PDF File for Signing: +certSign.jksNote=Note: If your certificate type is not listed below, please convert it to a Java Keystore (.jks) file using the keytool command line tool. Then, choose the .jks file option below. certSign.selectKey=Select Your Private Key File (PKCS#8 format, could be .pem or .der): certSign.selectCert=Select Your Certificate File (X.509 format, could be .pem or .der): certSign.selectP12=Select Your PKCS#12 Keystore File (.p12 or .pfx) (Optional, If provided, it should contain your private key and certificate): +certSign.selectJKS=Select Your Java Keystore File (.jks or .keystore): certSign.certType=Certificate Type certSign.password=Enter Your Keystore or Private Key Password (If Any): certSign.showSig=Show Signature diff --git a/src/main/resources/templates/security/cert-sign.html b/src/main/resources/templates/security/cert-sign.html index fbbf36d18..20148355d 100644 --- a/src/main/resources/templates/security/cert-sign.html +++ b/src/main/resources/templates/security/cert-sign.html @@ -1,135 +1,113 @@ - - - + + -

-
-
-

-
-
-
-

- -
-
- -
-
-
- +
+
+
+

+
+
+
+

+ +
+ +
+
+ +
+
+
+ +
+ + + +
+ +
+
+ +
+ +
+ +
+ +
+
+
+
+
+
+ - - -
- -
- -
-
-
-
-
-
+ document + .getElementById('showSignature') + .addEventListener( + 'change', + function() { + var signatureDetails = document.getElementById('signatureDetails'); + if (this.checked) { + signatureDetails.style.display = 'block'; + } else { + signatureDetails.style.display = 'none'; + } + }); + +