java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java PDFBox PDF数字签名

Java使用PDFBox实现PDF数字签名的全流程详解

作者:菩提风

做PDF数字签名,Java阵营里绕不开的一个库就是Apache PDFBox,这篇文章会覆盖数字证书准备、PDFBox核心API的使用、字节范围摘要的计算逻辑、签名写入与验证的完整流程,适合已经会用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签名的情况下再追加自己的签名。

由此产生三个关键限制:

所以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文件里。真正运行时的流程是:

  1. 调用 PDSignature 的 setContents ,传一个“足够长”的占位字节数组(通常全0)。
  2. PDFBox调用 document.saveIncremental 把签名占位符写入文件。
  3. 重新读取临时文件,计算 ByteRange 并生成摘要。
  4. 把摘要交给 SignatureInterface.sign ,得到签名值。
  5. 用文件流方式替换临时文件中的占位符内容,替换内容长度与占位符长度一致。

因为签名值长度是固定的(受证书链和算法影响),所以占位符长度要预留得比实际签名值大。如果预留太小,写入时就会报错。

预留多少合适?用一个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 。接下来的操作是:

  1. 读取临时文件。
  2. 定位签名占位符在文件中的偏移。
  3. 计算 ByteRange 数组: [0, 起始偏移, 结束偏移, 文件长度-结束偏移] 。
  4. 跳过字节范围中排除掉的占位符区域,对其余部分计算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之外的内容被改了,但恰好落在签名范围内,导致摘要不一致。

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容器抽出摘要。

扩展方向上,有几个实际使用中非常高频的需求:

8. 写在最后:两个关键心得

第一点,签名前务必搞清楚 ByteRange 的含义。PDFBox把字节范围计算的复杂细节封装掉了,但排查问题的时候绕不开。建议实现签名功能后,先打印一次 ByteRange 和文件总长度,手动对照一下,确认没有问题再进入下一步。这会为你省下大量排查时间。

第二点,生产环境一定要用合规数字证书。自签名证书在开发时方便,但用于正式业务会引起一系列信任问题。实测下来,用商业CA签发的证书跑通一次之后,PDFBox签名这块就基本固定住了,后面的工作量主要都在业务对接和异常处理上。

最后一个小技巧 :调试阶段,把临时文件保留下来,别急着删除。遇到“阅读器打不开”或者“签名无效”的时候,比较 origin.pdf 、 signed_temp.pdf 、 final_signed.pdf 三个文件的字节差异,多个对比工具同时看,问题基本一眼就能找出来。祝各位少踩坑,一次跑通。

以上就是Java使用PDFBox实现PDF数字签名的全流程详解的详细内容,更多关于Java PDFBox PDF数字签名的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文