Java使用PDFBox实现PDF数字签名的全流程详解
作者:菩提风
做PDF数字签名,Java阵营里绕不开的一个库就是Apache PDFBox。最近帮朋友处理一个合同归档系统,需要在生成的PDF上自动加盖可验证的电子签名,用的就是PDFBox 2.0系列。整个流程踩了不少坑,从“签名后文件打不开”到“签完名但阅读器提示签名无效”,都经历过。整理一篇完整的实操笔记,给正在做同类功能的Java开发者参考。
这篇文章会覆盖数字证书准备、PDFBox核心API的使用、字节范围摘要的计算逻辑、签名写入与验证的完整流程,末尾附一个可复现的开源项目结构参考。适合已经会用PDFBox做基础PDF操作的开发者,也适合刚接触电子签名、想搞清楚“签名到底签的是什么”的新手。
1. PDF数字签名这件事,难点不在“签名”而在“PDF”
1.1 数字签名的基础逻辑
先说数字签名本身。不管签的是什么格式的文件,核心步骤都是一样的:对待签名内容做摘要(哈希),用私钥对摘要加密,生成签名值;验证时用公钥解密签名值,拿到摘要,再对文件重新计算摘要,两个摘要相同则签名有效。
Java里做这件事有现成的标准库支持。 Signature 类配合对应的算法名就能完成签名和验签:
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(privateKey);
signature.update(data);
byte[] signed = signature.sign();
难的地方在于,PDF格式对“待签名内容”的定义非常严格。签名者选择的签名范围必须是一个 连续的字节区间 ,而且这个区间在签名完成之后不能再有任何变化,否则验证时重新计算的摘要就会对不上,签名直接失效。
1.2 PDF签名模型的特殊之处
PDF的签名机制和普通文件签名最大的不同在于:PDF允许在文件尾部追加内容,同时保证原始字节不变。这个设计是为了支持“多 人轮流签名”的场景——A签完名,B在不破坏A签名的情况下再追加自己的签名。
由此产生三个关键限制:
- 签名保护的是文件中的某一段字节范围,不一定是整个文件。 通常保护的范围是“从文件开头到签名对象之前的所有字节”。
- 签名后不能修改已保护范围内的任何字节。 这意味着修改PDF元数据、页面内容、字典对象,都会让已有签名失效。
- 签名对象必须预留足够的空间。 因为签名值、证书链等信息要写进PDF文件内部,如果在计算摘要之后再写入数据导致文件长度变化,哈希结果就变了。
所以PDFBox做签名时,内部逻辑是:先在PDF中放置一个“空”的签名对象,计算字节范围摘要,再在预留空间内填入真实签名值。整个过程的顺序完全不能颠倒。
2. 准备工作:密钥库、测试证书、项目依赖
2.1 用keytool生成自签名测试证书
开发和测试阶段不需要去商业CA申请证书,用JDK自带的 keytool 生成一个自签名证书就能跑通流程。生产环境再换成合规的数字证书。
生成一个PKCS12格式的密钥库,命令如下:
keytool -genkeypair \ -alias sign-demo \ -keyalg RSA \ -keysize 2048 \ -validity 3650 \ -dname "CN=Sign Demo, OU=Dev, O=Example, L=Beijing, ST=Beijing, C=CN" \ -storetype PKCS12 \ -keystore keystore.p12 \ -storepass changeit
生成后得到一个 keystore.p12 文件,里面包含私钥和对应的公钥证书。注意当前密码占位用 changeit ,正式项目里不要用这种弱密码,密钥库文件本身也要放到受保护的存储里,别打进工程包。
2.2 引入PDFBox依赖
Maven项目里,PDFBox的核心依赖和加密组件需要同时引入:
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.31</version>
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox-tools</artifactId>
<version>2.0.31</version>
</dependency>PDFBox的签名功能依赖底层的CMS(Cryptographic Message Syntax)数据结构,也就是PKCS#7格式的签名容器。这些类库本身随 pdfbox 传递引入,不需要额外手动配置。只是某些高级功能(比如RFC3161时间戳)需要额外的BC库支持,这次没用到,先不展开。
如果正在用PDFBox 3.0,API上有一些变化,比如签名接口的包名和部分方法名有调整,逻辑相近,但代码不能直接照搬。下面所有示例基于稳定的2.0系列。
3. 核心逻辑解析:PDFBox签名API怎么用才对
3.1 签名字典与外观设置
PDFBox中,一次签名对应一个 PDSignature 对象。这个对象要挂在文档的 Signature 字典列表里,同时指定签名出现的页面和显示区域(可见签名)或不做任何显示(不可见签名)。
核心代码如下:
PDDocument document = PDDocument.load(new File("origin.pdf"));
PDSignature signature = new PDSignature();
signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED);
signature.setName("Sign Demo");
signature.setLocation("Beijing");
signature.setReason("Contract Approval");
signature.setSignDate(Calendar.getInstance());
这里有两个参数需要特别解释。
Filter 固定为 Adobe.PPKLite ,这是PDF规范里预定义的签名过滤器值,阅读器根据这个值识别签名对象。除非有特殊需求,否则不要改。
SubFilter 则决定了签名值的封装格式。 adbe.pkcs7.detached 表示签名值是一个PKCS#7容器,但原始内容(被签名的字节范围)不包含在容器里,而是由PDF文件本身承载。这也是目前最通用的格式,Adobe阅读器和大多数开源阅读器都支持。
3.2 签名接口与签名字节
接下来是签名接口的定义。PDFBox要求调用方提供一个 SignatureInterface 实现,重写 sign 方法,接收PDFBox算好的摘要字节数组,返回完整的CMS签名字节流。
public class Pkcs12Signature implements SignatureInterface {
private final PrivateKey privateKey;
private final X509Certificate[] certificateChain;
public Pkcs12Signature(KeyStore keystore, String alias, char[] password) throws Exception {
this.privateKey = (PrivateKey) keystore.getKey(alias, password);
this.certificateChain = ((KeyStore.PrivateKeyEntry) keystore.getEntry(alias, new KeyStore.PasswordProtection(password)))
.getCertificateChain();
Certificate[] chain = certificateChain;
this.certificateChain = Arrays.copyOf(chain, chain.length, X509Certificate[].class);
}
@Override
public byte[] sign(byte[] content) throws Exception {
// 这里后续填入CMS签名构造逻辑
return new byte[0];
}
}
这里的 content 参数就是之前提到的“字节范围摘要”,由PDFBox在计算完 ByteRange 之后传入。在这个方法里,要把PDFBox计算出的摘要嵌入到CMS签名数据中,构造完整的PKCS#7容器。
3.3 关键设计:字节范围、哈希、占位符大小
PDFBox的 Signature 对象有一个 setContents 方法,传入的字节数组会写入PDF文件里。真正运行时的流程是:
- 调用
PDSignature的setContents,传一个“足够长”的占位字节数组(通常全0)。 - PDFBox调用
document.saveIncremental把签名占位符写入文件。 - 重新读取临时文件,计算
ByteRange并生成摘要。 - 把摘要交给
SignatureInterface.sign,得到签名值。 - 用文件流方式替换临时文件中的占位符内容,替换内容长度与占位符长度一致。
因为签名值长度是固定的(受证书链和算法影响),所以占位符长度要预留得比实际签名值大。如果预留太小,写入时就会报错。
预留多少合适?用一个2560字节的占位符基本能覆盖单证书链+RSA 2048+SHA256的场景。如果证书链较长或者加了时间戳,需要加大。
4. 实操:完整实现一次PDF数字签名
4.1 初始化密钥库与签名接口
先加载PKCS12密钥库,拿到私钥和证书链:
KeyStore keystore = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream("keystore.p12")) {
keystore.load(fis, "changeit".toCharArray());
}
String alias = "sign-demo";
Pkcs12Signature signer = new Pkcs12Signature(keystore, alias, "changeit".toCharArray());
4.2 创建签名对象并预留占位符
构建 PDSignature 对象并调用 document.setDocumentId :
PDDocument document = PDDocument.load(new File("origin.pdf"));
PDSignature signature = new PDSignature();
signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED);
signature.setName("Sign Demo");
signature.setLocation("Beijing");
signature.setReason("Approved");
signature.setSignDate(Calendar.getInstance());
// 占位符:长度与最终签名值大小匹配
signature.setContents(new byte[8192]);
document.addSignature(signature, signer);
document.saveIncremental(new FileOutputStream("signed_temp.pdf"));
document.close();
注意 saveIncremental 这一点。PDFBox的 addSignature 加签名保存时,默认就是增量保存,不会重写整个PDF结构。这是必须的,因为重写会破坏原有的字节偏移,导致签名范围失效。
4.3 计算字节范围摘要并构造CMS签名
这是整个流程里最容易出错的部分。 saveIncremental 之后,签名内容占位符已经被写进 temp_signed.pdf 。接下来的操作是:
- 读取临时文件。
- 定位签名占位符在文件中的偏移。
- 计算
ByteRange数组:[0, 起始偏移, 结束偏移, 文件长度-结束偏移]。 - 跳过字节范围中排除掉的占位符区域,对其余部分计算SHA256摘要。
PDFBox内部在调用 SignatureInterface.sign 之前,会先把待摘要的字节内容准备好。但实际操作时,这里完全可以在自己的 sign 方法里手动计算,逻辑更清晰:
@Override
public byte[] sign(byte[] content) throws Exception {
CMSSignedDataGenerator generator = new CMSSignedDataGenerator();
ContentSigner contentSigner = new JcaContentSignerBuilder("SHA256withRSA").setProvider("BC").build(privateKey);
generator.addSignerInfoGenerator(new JcaSignerInfoGeneratorBuilder(
new JcaDigestCalculatorProviderBuilder().setProvider("BC").build())
.build(contentSigner, certificateChain[0]));
generator.addCertificates(new JcaCertStore(Arrays.asList(certificateChain)));
CMSProcessableByteArray processable = new CMSProcessableByteArray(content);
CMSSignedData signedData = generator.generate(processable, false);
// 注意:此处传入的 content 是PDFBox传入的待签名摘要
return signedData.getEncoded();
}
这里有一个要注意的点: generator.generate 的第二个参数 false 表示不把原始数据包进CMS容器,对应 detached 模式。
内容签名器的算法名称要与私钥类型匹配。RSA私钥用 SHA256withRSA ,EC私钥用 SHA256withECDSA 。不一致会直接抛异常。
4.4 将签名值写回占位符
拿到CMS签名值后,用随机访问文件写入,替换占位符区域。因为占位符长度是预设的,写的字节数必须小于等于占位符长度,不足部分保持0填充,不能让文件长度变化。
try (RandomAccessFile raf = new RandomAccessFile("signed_temp.pdf", "rw")) {
raf.seek(signatureOffset);
raf.write(signedBytes);
}
替换之后, signed_temp.pdf 就是完整可用的签名文件。记得把临时文件重命名回最终文件名。
整个“临时文件签名替换”的流程可以用一个工具方法封装,运行时顺序是固定的:先加载原始PDF,创建签名占位,增量保存到临时文件,再找到偏移写回签名值。中间任何一步都不能调换顺序。
5. 验证签名:自校验与客户端校验
签名写完必须验证,不然等于白做。验证分两个层面:程序化校验和人工校验。
5.1 用PDFBox做程序化校验
用PDFBox读取签名信息:
PDDocument document = PDDocument.load(new File("signed.pdf"));
List<PDSignature> signatures = document.getSignatureDictionaries();
for (PDSignature sig : signatures) {
byte[] content = sig.getContents();
// 解析CMS结构,取出签名时间、证书链、摘要等信息
CMSSignedData cms = new CMSSignedData(content);
}
拿到 CMSSignedData 之后,可以验证证书链的合法性,也可以把签名原始数据再次重新计算摘要,判断文件自签名之后有没有被修改。
5.2 从字节范围角度校验
PDFBox提供了 PDDocument.getLastCertificateDictionary 来辅助验证签名,但更底层的做法是从 ByteRange 入手:
- 读取签名字典里的
/ByteRange数组,得到4个整数。 - 对范围内所有字节计算SHA256摘要。
- 从CMS容器中取出签名摘要,对比。
这个手动流程能排查很多诡异问题,比如“明明签名成功了,为什么阅读器总提示文档被修改”。多数时候是ByteRange之外的内容被改了,但恰好落在签名范围内,导致摘要不一致。
5.3 用阅读器人工验证
最直观的验证方式是直接用主流PDF客户端打开,查看签名面板。正常签名和正常证书的情况下,阅读器会显示“签名有效”;自签名证书会显示“无法验证作者”,但至少不会提示“文档自签名后被修改”。
这里再加一个检查点:如果直接看签名面板里显示的摘要值,和自己算的摘要对不上,那就说明签名写入时没有对齐ByteRange,基本就是占位符偏移算错了。
6. 常见问题与排错清单
把实际操作中遇到的坑整理成一份速查表。
| 异常现象 | 可能原因 | 解决办法 |
|---|---|---|
IOException: Corrupt PDF | 签名占位符写入后文件偏移失效 | 确认是增量保存,且不能重新保存整个文档 |
| 签名显示“文档被修改” | ByteRange计算错误或占位符长度变化 | 检查签名前后文件长度是否变化,ByteRange是否覆盖签名区之前的所有字节 |
SignatureInterface.sign 返回空数组 | 签名算法构造错误 | 确认ContentSigner算法与私钥类型匹配 |
| 签名写入后阅读器打不开文件 | 占位符被真实签名值替换后格式错乱 | 检查写回时是否用随机访问保持长度一致 |
| 第二次签名覆盖第一次签名 | 对同一个文档重新调用addSignature | 必须在签名后的完整文档基础上做增量追加,不能直接替换原文件再签 |
| 证书链长度过大导致签名值超出占位符 | 占位长度预估不足 | 先按预估签名值计算,再+256~512字节冗余 |
| 自签名证书提示无法验证作者 | 证书不是受信任CA签发 | 开发环境忽略或导入根证书;生产使用CA签发的合规证书 |
| 中文名称乱码 | 签名对象里使用了非ASCII字符且字体问题 | 设置PDF文档的中文字体,或避免在name字段填中文 |
最隐蔽的问题是“签名文件本身能打开,签名信息也在,但任何阅读器都显示签名失效”。这种情况大概率是占位符偏移和ByteRange偏移不匹配。修剪一下思路:先把 ByteRange 打印出来,手动用十六进制工具检查文件对应偏移位置,是否是 0 占位符,再确认CMS签名的起始位置是否正好对应 ByteRange[2] 。
还有一个容易忽视的坑: 签名之后不要再调用 PDDocument.save() 。一旦保存,整个PDF会被重写,之前的签名全部失效。如果需要同时做多层签名,务必基于上一版本的文件做增量保存,不要回退到原始文件再操作。
7. 附录:开源项目结构参考与扩展思路
很多人在这一步会卡住,不知道代码该拆成哪些类。这里给出一份可直接参考的项目结构:
pdfbox-sign-demo/
├── pom.xml
└── src/main/java/
├── sign/
│ ├── Pkcs12Signature.java // 签名接口实现,负责CMS构造
│ ├── PdfSigner.java // 签名流程编排
│ ├── SignatureValidator.java // 验证逻辑
│ └── ByteRangeUtil.java // 字节范围计算
└── Main.java // 命令行入口
开源项目的实际托管地址可以在GitHub搜索关键词 pdfbox sign 找到不少可参考仓库。拿到源码之后,重点看三个部分:签名占位符的大小如何计算、ByteRange的偏移怎么定位、验证时如何从CMS容器抽出摘要。
扩展方向上,有几个实际使用中非常高频的需求:
- 叠加时间戳 :让签名值带上RFC3161时间戳,保证签名时间可信。PDFBox原生支持有限,需要在CMS生成阶段额外接入TSA客户端。
- 多级签名(会签审批) :在已经签名的文档上继续增量签名。逻辑与单次签名基本一致,但要注意每个签名对象都会增加一个
ByteRange,必须逐一计算内容。 - 批量签名 :把PDF模板循环处理。批量场景下特别要注意“同一份模板生成多份文件”时,要基于模板复制出来的新文件做签名,不要在模板对象上重复增量保存。
- 可见签名外观 :调用
setPage和setRectangle配合图像图层,实现手写签字效果。外观不影响签名有效性,但影响用户体验。
8. 写在最后:两个关键心得
第一点,签名前务必搞清楚 ByteRange 的含义。PDFBox把字节范围计算的复杂细节封装掉了,但排查问题的时候绕不开。建议实现签名功能后,先打印一次 ByteRange 和文件总长度,手动对照一下,确认没有问题再进入下一步。这会为你省下大量排查时间。
第二点,生产环境一定要用合规数字证书。自签名证书在开发时方便,但用于正式业务会引起一系列信任问题。实测下来,用商业CA签发的证书跑通一次之后,PDFBox签名这块就基本固定住了,后面的工作量主要都在业务对接和异常处理上。
最后一个小技巧 :调试阶段,把临时文件保留下来,别急着删除。遇到“阅读器打不开”或者“签名无效”的时候,比较 origin.pdf 、 signed_temp.pdf 、 final_signed.pdf 三个文件的字节差异,多个对比工具同时看,问题基本一眼就能找出来。祝各位少踩坑,一次跑通。
以上就是Java使用PDFBox实现PDF数字签名的全流程详解的详细内容,更多关于Java PDFBox PDF数字签名的资料请关注脚本之家其它相关文章!
