问题描述
我们有一个特定的业务/技术案例 - 我们需要对 PDF 进行电子签名,为此,我们正在与一家处理 PDF 哈希电子签名的公司合作。
我们或多或少知道如何从 PDF 中获取哈希值,并在外部服务通过 Rest API 签名后将其合并回来 - 但此过程中的某些内容会导致 PDF 显示“文档自签名以来已被更改或损坏” .
我们是如何做的
我们正在使用库 Apache PDFBox 版本 3.0.0(我们也尝试了不同的版本),我们的解决方案基于官方 Apache 示例:
我们在 stackoverlow 上阅读了许多描述与我们类似的问题的帖子,例如:
我们决定采用此处描述的解决方案: Java PdfBox - PDF 签名问题 - 外部签名 - 无效签名 此签名中包含的格式或信息存在错误
我们的主类 - 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);
}
}
}
模拟外部哈希签名过程的类 -ExternalSignature
我使用以下命令创建了带有自签名私钥的本地密钥库:
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;
}
}
Apache 示例中使用的其他类:
请帮助我们
据我们了解该网站上围绕类似问题的其他帖子,一切都应该有效,但事实并非如此。 我们很可能错过了一些关键步骤。
我们期待任何回复,每一条信息都可以帮助我们。
我不明白的是为什么 PDF 的摘要(哈希)这么小:
LXl/LHCaxrf7lYlN8d8m7gDNp9DRqY+azvxCS/mB3uY=
看起来尺寸正确吗?
PDF 示例和公钥/私钥
Base64 中的公钥:
MIIDUTCCAjmgAwIBAgIEZPgGSjANBgkqhkiG9w0BAQsFADBZMQswCQYDVQQGEwJURTENMAsGA1UECBMEVGVzdDENMAsGA1UEBxMEVGVzdDENMAsGA1UEChMEVGVzdDENMAsGA1UECxMEVGVzdDEOMAwGA1UEAxMFSmFuIFMwHhcNMjMwODI0MTgzMTM2WhcNMjQwODIzMTgzMTM2WjBZMQswCQYDVQQGEwJURTENMAsGA1UECBMEVGVzdDENMAsGA1UEBxMEVGVzdDENMAsGA1UEChMEVGVzdDENMAsGA1UECxMEVGVzdDEOMAwGA1UEAxMFSmFuIFMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCEO5+UBxpzSI5vtmPvzlyz0d1yHph33VG9bFkqQ7bnmE3ghHiFyGTBrD7IPTrBg1NjhdC5jqpa/acY/oWUAwV5dlyD02nVBoy7VBLvz7QOc9QPaUQ2frVY4Qy05qgLZEGgnbpG2X44sqV8ogW1y2IaY+2F9NBKralp8r6M/sB4DKJmTWd+1UOlpnlfr3H5KvX+YXzfC+KVDIEoG7yzXZuw7I3x0VawVz/5gx3FAkagi7yHh9J58kmImpOFquxcK6SdMCoBsWmFmHmCpExVfxrVLMpIV5a5AHDWn84YHHRKC5kvhnyujXaIHFV+78TFxk8DGnPnKcgFRWKRVyfcYT7hAgMBAAGjITAfMB0GA1UdDgQWBBQFR8qbfk35MQdMqxQhZ2kkLoLPDDANBgkqhkiG9w0BAQsFAAOCAQEAI6aAn6zKcRieUkyHraRswYhxRjpc1fDaeDz01XanqxUIpNf31dU+f62oo5Dv4VAgd6MzLdFSpcERB9ScJzIIrz7mnqS0r8LhesDejs/mnDg2+E89XHy7yZtU3MqAXmESe7qms8AB1F68YZ+OOjWfn1AZfjaAzU0La49NaFbQE96vBLBGNX2Efavk7trv9PMCKMio19w/FBGwH8cU0erAi/WZhRlX3C1eFfmGSs5BWZL03yOmdeR/PVNut135dbk0EJ9rn6TJp1KgoNqcnsbPZcSxmUB/3U/hWUoFb6UN7g+1NbVh0PHCZ2Kbwyk6mO2viNbXka1WuopUxGh1RmJW9A==
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=
分析您的示例文件,我们会发现签名中的哈希值都不正确。此外,人们发现 CMS 签名容器并不是您的黑客工作所需的简单形式。
您希望创建一个简单的签名容器(没有签名属性,签名字节直接对数据进行签名),以便您的
ContentSigner
黑客工作。
不过,您的示例文件包含带有签名属性的签名容器。因此,签名属性和签名字节中的哈希都是错误的,前者是因为您使用
"not used".getBytes()
作为生成器输入,后者是因为它不是基于签名属性。
原因是您没有完全复制用作模板的 answer 中的代码:您在那里删除了
JcaSignerInfoGeneratorBuilder
方法 setDirectSignature
的调用:
JcaSignerInfoGeneratorBuilder sigb = new JcaSignerInfoGeneratorBuilder(new JcaDigestCalculatorProviderBuilder().build());
sigb.setDirectSignature(true);
但是,如果
JcaSignerInfoGeneratorBuilder
设置为 DirectSignature
,则 true
仅创建简单的签名容器结构。
因此,请填写该代码的副本。
即使切换到创建简单签名容器后,您也会签署错误的哈希值,因为在
ExternalSignature
开头使用 MessageDigest.getInstance("SHA256", new BouncyCastleProvider())
对数据进行哈希处理后,您的流程使用 CreateSignature.sign
测试替换进行哈希处理,然后使用 Signature.getInstance("SHA256withRSA")
再次进行哈希处理
在 ExternalSignature.sign
。
要使该流程正常工作,您必须删除其中一项哈希计算。
不过,我不知道您的
ExternalSignature
测试替换是否真正正确地代表了您的 ServerSignature
功能。如果后者不散列自身,但确实需要预先散列的数据,那么在切换到创建简单签名容器后,您的流程可能会毫不费力地工作。
为什么坚持创建简单的签名容器?每个需要认真对待的签名配置文件都需要使用签名属性。