Signing with Apache PDFBox & EU DSS
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
A signature on a PDF is a CMS container stored in a placeholder that was reserved inside the file. Two libraries that are free of the iText licence can produce it. Apache PDFBox is a general PDF library, licensed under Apache-2.0, that reserves the placeholder, computes the byte range and writes the incremental update, and leaves the CMS container to you. The European Commission’s Digital Signature Service (EU DSS, licensed under LGPL-2.1) is a framework that builds the container, the timestamps and the validation data for you and produces PAdES baseline signatures at all four levels, using PDFBox underneath to write the file. This page shows both with the Bookshelf Billing invoice: a signature with a callback, a signature with the key outside the process, a visible signature, a signature timestamp and a document timestamp, validation data for long-term validation, the DSS equivalent of each step, and the case where the PDF was produced by JasperReports and has to be signed afterwards. Versions are stated as of 2026-10-09: Apache PDFBox 3.0.8, EU DSS 6.5, Bouncy Castle 1.86 and JasperReports 7.0.8 on Java 21. Every Java example on this page was compiled and run against exactly those versions, and the results were read back and verified; the section "What was run, and what was not" says what that does and does not prove.
What a signature is, why the file is signed in an incremental update, and how to read /ByteRange and /Contents, are in
Digital Signatures: Fundamentals, which also owns the
complete script that creates the test certificates used here. The four PAdES levels and the standards behind them are
explained in PAdES Baseline Levels. The same flows with iText
are in Signing PDFs with iText. Keeping the private key in an HSM,
a key management service or a remote signing service is in
Remote, HSM & Deferred Signing, and checking what was
signed, and keeping a signature valid for years, is in
Signature Validation & Long-Term Validity.
PDFBox or EU DSS
The two libraries are not rivals. PDFBox is the file layer and DSS is the signature layer built on it, so the decision is how much of the signature layer you want to write yourself.
| Apache PDFBox 3.0.8 | EU DSS 6.5 | |
|---|---|---|
Licence |
Apache-2.0 |
LGPL-2.1 |
What it gives you |
Placeholder, |
Complete PAdES B-B, B-T, B-LT and B-LTA, extension of existing signatures, document timestamps, validation |
What you write |
The CMS container (Bouncy Castle), the signature timestamp, the validation data |
Choice of parameters, a token, a TSP source, a certificate verifier |
Dependencies |
|
Around ten modules; you must pick implementations for utilities, CMS and CRL parsing |
Typical use |
Simple approval signatures, signing in an existing PDFBox pipeline, learning how it works |
Signatures that must reach B-LT or B-LTA, or be validated against the EU trusted lists |
Both are an alternative to iText 9, which is released under the AGPLv3 or under a commercial licence (see
iText Add-ons for how that licence is applied). iText’s PdfPadesSigner
does the work of the DSS service in one call; PDFBox alone does less than iText’s low-level PdfSigner. If the
documents leave the company and the AGPL network clause is a problem, one of the two libraries of this page is the usual
answer, and DSS is the one that gives the baseline levels.
Setup: coordinates and test certificates
PDFBox signing needs pdfbox (which brings pdfbox-io and fontbox) and Bouncy Castle’s bcpkix-jdk18on for the CMS
classes. PDFBox 3.0.8 declares Bouncy Castle 1.84 in its own build; the examples on this page used 1.86, the current
version as of 2026-10-09, and the one place where that mattered is described in the section on validation data. DSS 6.5
is split in many modules, and the surprising part is that some are mandatory choices: without an implementation of the
utilities, of CMS generation and of CRL parsing on the classpath the service fails at start-up. The artifacts below are
the set that was needed, and each of the three failures that taught this was an exception that names the alternatives:
No implementation found for IUtils in classpath, please choose between dss-utils-apache-commons or dss-utils-google-guava,
No implementation found for CMSGenerator in the classpath, please choose between 'dss-cms-object' or 'dss-cms-stream'!
and No implementation found for ICRLUtils in classpath, please choose between dss-crl-parser-stream or dss-crl-parser-x509crl.
<properties>
<maven.compiler.release>21</maven.compiler.release>
<dss.version>6.5</dss.version>
</properties>
<dependencies>
<!-- Apache PDFBox, Apache-2.0 -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.8</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>1.86</version>
</dependency>
<!-- EU DSS, LGPL-2.1: the PAdES service, written on top of PDFBox -->
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-pades-pdfbox</artifactId>
<version>${dss.version}</version>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-token</artifactId>
<version>${dss.version}</version>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-service</artifactId>
<version>${dss.version}</version>
</dependency>
<!-- implementations that DSS looks up at start-up: pick one of each pair -->
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-utils-apache-commons</artifactId>
<version>${dss.version}</version>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-cms-object</artifactId>
<version>${dss.version}</version>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-crl-parser-x509crl</artifactId>
<version>${dss.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.17</version>
</dependency>
</dependencies>
The artifact names are those of dss-pades on Maven Central
and of the DSS documentation; dss-pades itself arrives transitively through dss-pades-pdfbox. dss-cms-object keeps
the CMS container in memory, while dss-cms-stream is meant for very large documents. dss-pades-openpdf is the
alternative PDF layer on top of OpenPDF; it supports fewer features, for example no text and image together in a
visible signature, according to the DSS documentation.
The examples reuse the naming convention of the section. The keystore billing-signer.p12 holds the private key under
the alias billing-signer together with its certificate chain, billing-test-ca.p12 is the root CA that issued it, and
billing-test-ca.pem is the CA certificate exported as text. The complete script is on
Digital Signatures: Fundamentals; as a reminder, its
core is the following, run with the store password in the environment variable BILLING_KEYSTORE_PASSWORD (a throw-away
placeholder such as changeit-placeholder is fine for a test run, a real value never belongs in a file).
keytool -genkeypair -alias billing-test-ca -keyalg RSA -keysize 3072 -sigalg SHA256withRSA \
-dname "CN=Bookshelf Billing Test CA, O=Bookshelf Test, C=ES" -validity 3650 \
-ext bc:c -ext ku:c=keyCertSign,cRLSign \
-keystore billing-test-ca.p12 -storetype PKCS12 -storepass:env BILLING_KEYSTORE_PASSWORD
keytool -genkeypair -alias billing-signer -keyalg RSA -keysize 3072 -sigalg SHA256withRSA \
-dname "CN=Bookshelf Billing Signer, O=Bookshelf Test, C=ES" -validity 825 \
-keystore billing-signer.p12 -storetype PKCS12 -storepass:env BILLING_KEYSTORE_PASSWORD
keytool -certreq -alias billing-signer -keystore billing-signer.p12 \
-storepass:env BILLING_KEYSTORE_PASSWORD -file billing-signer.csr
keytool -gencert -alias billing-test-ca -keystore billing-test-ca.p12 -storepass:env BILLING_KEYSTORE_PASSWORD \
-infile billing-signer.csr -outfile billing-signer.crt -rfc -validity 825 \
-ext ku:c=digitalSignature,nonRepudiation -ext eku=emailProtection
keytool -exportcert -rfc -alias billing-test-ca -keystore billing-test-ca.p12 \
-storepass:env BILLING_KEYSTORE_PASSWORD -file billing-test-ca.pem
keytool -importcert -noprompt -alias billing-test-ca -file billing-test-ca.pem \
-keystore billing-signer.p12 -storepass:env BILLING_KEYSTORE_PASSWORD
cat billing-signer.crt billing-test-ca.pem > chain.pem
keytool -importcert -noprompt -alias billing-signer -file chain.pem \
-keystore billing-signer.p12 -storepass:env BILLING_KEYSTORE_PASSWORD
The result of the last command is a PKCS#12 file whose billing-signer entry carries the chain signer certificate then
CA certificate; keytool -list -v shows it as a certificate chain of length 2. Documentation for the tool is in the
keytool reference for JDK 21. The first helper
of the page loads the key and the chain, and the second creates a stand-in for the unsigned PDF with PDFBox itself, so
that the examples need no other input; a JasperReports-generated file is used in its own section.
package billing;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
/** The private key and certificate chain of the Bookshelf Billing test signer. */
public record SigningMaterial(PrivateKey privateKey, X509Certificate[] chain) {
public static SigningMaterial load(Path pkcs12) throws IOException, GeneralSecurityException {
char[] storePassword = System.getenv("BILLING_KEYSTORE_PASSWORD").toCharArray();
KeyStore store = KeyStore.getInstance("PKCS12");
try (InputStream in = Files.newInputStream(pkcs12)) {
store.load(in, storePassword);
}
PrivateKey signerKey = (PrivateKey) store.getKey("billing-signer", storePassword);
Certificate[] raw = store.getCertificateChain("billing-signer");
X509Certificate[] chain = new X509Certificate[raw.length];
for (int i = 0; i < raw.length; i++) {
chain[i] = (X509Certificate) raw[i];
}
return new SigningMaterial(signerKey, chain);
}
public X509Certificate signerCertificate() {
return chain[0];
}
}
package billing;
import java.io.IOException;
import java.nio.file.Path;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
/** Stand-in for the unsigned PDF that iText or JasperReports produces. */
public final class Invoice {
private Invoice() {
}
public static void create(Path target, String number) throws IOException {
try (PDDocument document = new PDDocument()) {
PDPage page = new PDPage();
document.addPage(page);
try (PDPageContentStream content = new PDPageContentStream(document, page)) {
content.beginText();
content.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 14);
content.newLineAtOffset(72, 720);
content.showText("Bookshelf Billing - invoice " + number);
content.endText();
}
document.save(target.toFile());
}
}
}
Both examples derive from the PDFBox signing examples in CreateSignature.java, which loads a keystore and uses its first alias; the version above names the alias explicitly.
How PDFBox signs
The class PDSignature models the signature dictionary: its filter (Adobe.PPKLite), its SubFilter (adbe.pkcs7.detached
or ETSI.CAdES.detached), the signer’s name, location, reason, contact information and signing date. PDFBox 3 declares
the constants PDSignature.FILTER_ADOBE_PPKLITE, SUBFILTER_ADBE_PKCS7_DETACHED and SUBFILTER_ETSI_CADES_DETACHED,
and SUBFILTER_ADBE_PKCS7_SHA1 is kept only for old files and must not be used for new signatures. The interface SignatureInterface has one method, sign(InputStream), that
receives the bytes covered by /ByteRange and returns the encoded CMS container. Two methods of PDDocument use it.
-
addSignature(PDSignature, SignatureInterface, SignatureOptions)registers a signature and a callback. When you callsaveIncremental(OutputStream), PDFBox writes the file with a zero-filled placeholder in/Contents, computes/ByteRange, calls yoursignmethod with the covered bytes and patches the result into the file. This is the simple path, and it is the only one in which PDFBox produces the whole file. -
addSignature(PDSignature)without a callback, followed bysaveIncrementalForExternalSigning(OutputStream), returns anExternalSigningSupportobject. ItsgetContent()returns the covered bytes as a stream, and itssetSignature(byte[])patches the container in afterwards. Nothing forces the signing to happen inside the method that saved the file, which is what makes the path suitable for a signing service, a hardware token, or an approval step between the two calls.
The distinction between the two paths is only one of control flow. In both, the placeholder is a fixed number of bytes
(the default reservation is around 9,500 bytes, and SignatureOptions.setPreferredSignatureSize changes it), and a
container that does not fit fails the call. The container grows with every certificate that is embedded and with every
timestamp, so a signature with a TSA token and a chain of three certificates needs more space than a bare B-B signature;
the examples on this page reserve 18,000 bytes.
The diagram shows the external path. With the callback path the arrow from the application to CmsSigner is drawn by
PDFBox itself inside saveIncremental.
The CMS container with Bouncy Castle
PDFBox does not build the container; the example below does, with Bouncy Castle’s CMSSignedDataGenerator. It signs with
SHA256withRSA, embeds the whole chain, and adds the ESS signing-certificate-v2 attribute (RFC 5035) that the PAdES
baseline requires in a ETSI.CAdES.detached signature, and which is the difference between a CMS signature and a CAdES
signature. Without it the file holds a plain CMS signature under a CAdES SubFilter, which a PAdES validator may reject.
When a TSA client is given, the class adds a signature timestamp as an unsigned attribute, which is the PAdES B-T level
(the TSA client is shown in the section on timestamps). The content is read into memory with readAllBytes(); for
files in the hundreds of megabytes, use a streaming CMSTypedData implementation, as the PDFBox example
CMSProcessableInputStream does.
package billing;
import java.io.IOException;
import java.io.InputStream;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.bouncycastle.asn1.ASN1EncodableVector;
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.CMSAttributes;
import org.bouncycastle.asn1.ess.ESSCertIDv2;
import org.bouncycastle.asn1.ess.SigningCertificateV2;
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.cert.jcajce.JcaCertStore;
import org.bouncycastle.cms.CMSException;
import org.bouncycastle.cms.CMSProcessableByteArray;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.CMSSignedDataGenerator;
import org.bouncycastle.cms.DefaultSignedAttributeTableGenerator;
import org.bouncycastle.cms.SignerInformation;
import org.bouncycastle.cms.SignerInformationStore;
import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
/**
* Builds the detached CMS container that goes into /Contents. It is what PDFBox calls back
* with the bytes covered by /ByteRange.
*/
public final class CmsSigner implements SignatureInterface {
private final SigningMaterial material;
private final TsaClient tsa; // null: no signature timestamp (B-B)
public CmsSigner(SigningMaterial material, TsaClient tsa) {
this.material = material;
this.tsa = tsa;
}
@Override
public byte[] sign(InputStream content) throws IOException {
try {
X509Certificate signer = material.signerCertificate();
// PAdES baseline requires the signing-certificate-v2 attribute (ESS, RFC 5035)
byte[] certDigest = MessageDigest.getInstance("SHA-256").digest(signer.getEncoded());
ESSCertIDv2 certId = new ESSCertIDv2(new AlgorithmIdentifier(NISTObjectIdentifiers.id_sha256), certDigest);
ASN1EncodableVector extra = new ASN1EncodableVector();
extra.add(new Attribute(PKCSObjectIdentifiers.id_aa_signingCertificateV2,
new DERSet(new SigningCertificateV2(certId))));
CMSSignedDataGenerator generator = new CMSSignedDataGenerator();
generator.addSignerInfoGenerator(
new JcaSignerInfoGeneratorBuilder(new JcaDigestCalculatorProviderBuilder().build())
.setSignedAttributeGenerator(new DefaultSignedAttributeTableGenerator(
new AttributeTable(extra)) {
@Override
@SuppressWarnings("rawtypes")
public AttributeTable getAttributes(Map parameters) {
// PAdES baseline forbids the CMS signing-time attribute; the claimed time is /M
return super.getAttributes(parameters).remove(CMSAttributes.signingTime);
}
})
.build(new JcaContentSignerBuilder("SHA256withRSA").build(material.privateKey()), signer));
generator.addCertificates(new JcaCertStore(Arrays.asList(material.chain())));
CMSSignedData signed = generator.generate(new CMSProcessableByteArray(content.readAllBytes()), false);
if (tsa != null) {
signed = addSignatureTimestamp(signed);
}
return signed.getEncoded();
} catch (GeneralSecurityException | CMSException | OperatorCreationException e) {
throw new IOException(e);
}
}
/** B-T: the TSA token over the signature value goes into the unsigned attributes. */
private CMSSignedData addSignatureTimestamp(CMSSignedData signed) throws IOException {
List<SignerInformation> updated = new ArrayList<>();
for (SignerInformation signerInfo : signed.getSignerInfos().getSigners()) {
byte[] token = tsa.timestamp(signerInfo.getSignature());
ASN1EncodableVector unsigned = new ASN1EncodableVector();
if (signerInfo.getUnsignedAttributes() != null) {
unsigned = signerInfo.getUnsignedAttributes().toASN1EncodableVector();
}
unsigned.add(new Attribute(PKCSObjectIdentifiers.id_aa_signatureTimeStampToken,
new DERSet(ASN1Primitive.fromByteArray(token))));
updated.add(SignerInformation.replaceUnsignedAttributes(signerInfo, new AttributeTable(unsigned)));
}
return CMSSignedData.replaceSigners(signed, new SignerInformationStore(updated));
}
}
One detail deserves attention. The official PDFBox example CreateSignatureBase builds the signer with
cert.getSigAlgName(), which is the algorithm that the issuer used to sign the certificate. It is often, but not always,
the right algorithm for the key in it, and a comment in the PDFBox example refers to the issue PDFBOX-5940 for that
reason. The class above names the algorithm explicitly instead. The RSA key size and algorithm must come from the key in
the keystore, not from the certificate: an RSA-PSS or ECDSA signer needs a different JcaContentSignerBuilder string,
and ETSI TS 119 312 V2.1.1 prefers those to PKCS#1 v1.5 for new signatures, as explained in
PAdES Baseline Levels.
The attribute is built from the SHA-256 digest of the signer certificate. The call to setSignedAttributeGenerator
replaces the default generator of signed attributes with one that keeps the content type and message digest, adds the
new attribute, and removes the signing-time attribute that Bouncy Castle adds by default: the PAdES baseline profile
(ETSI EN 319 142-1) forbids the CMS signing-time attribute, and the claimed signing time goes in the /M entry of the
signature dictionary instead, which PDFBox fills from setSignDate. Official documentation of the interface:
SignatureInterface in the PDFBox 3.0.8 javadoc.
The example derives from
CreateSignatureBase.java.
Signing with addSignature
The first PDFBox example signs with the callback. It reserves 18,000 bytes, fills the signature dictionary and calls
saveIncremental, which requires the output to be a different stream from the one the document was loaded from, and
which appends to the original bytes instead of rewriting the file; that is why earlier signatures survive.
package billing;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Calendar;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.ExternalSigningSupport;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureOptions;
public final class PdfBoxSigning {
private PdfBoxSigning() {
}
private static PDSignature dictionary(String reason) {
PDSignature signature = new PDSignature();
signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
signature.setSubFilter(PDSignature.SUBFILTER_ETSI_CADES_DETACHED);
signature.setName("Bookshelf Billing Signer");
signature.setLocation("Pamplona");
signature.setReason(reason);
signature.setSignDate(Calendar.getInstance());
return signature;
}
/** Callback style: PDFBox calls SignatureInterface.sign with the ByteRange content. */
public static void signWithCallback(Path in, Path out, SignatureInterface signer) throws IOException {
try (PDDocument document = Loader.loadPDF(in.toFile());
OutputStream output = Files.newOutputStream(out);
SignatureOptions options = new SignatureOptions()) {
options.setPreferredSignatureSize(18_000); // bytes reserved for /Contents
document.addSignature(dictionary("Invoice approval"), signer, options);
document.saveIncremental(output);
}
}
/** External style: PDFBox hands out the content, the caller returns the CMS container. */
public static void signExternally(Path in, Path out, SignatureInterface signer) throws IOException {
try (PDDocument document = Loader.loadPDF(in.toFile());
OutputStream output = Files.newOutputStream(out)) {
document.addSignature(dictionary("Invoice approval, external"));
ExternalSigningSupport external = document.saveIncrementalForExternalSigning(output);
byte[] cms;
try (InputStream content = external.getContent()) {
cms = signer.sign(content); // could just as well be a call to an HSM or a remote service
}
external.setSignature(cms);
}
}
/** A document timestamp (PAdES B-LTA building block): subfilter ETSI.RFC3161, no certificate. */
public static void addDocumentTimestamp(Path in, Path out, TsaClient tsa) throws IOException {
SignatureInterface timestamper = content -> tsa.timestamp(content.readAllBytes());
try (PDDocument document = Loader.loadPDF(in.toFile());
OutputStream output = Files.newOutputStream(out);
SignatureOptions options = new SignatureOptions()) {
PDSignature signature = new PDSignature();
signature.setType(COSName.DOC_TIME_STAMP);
signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
signature.setSubFilter(COSName.getPDFName("ETSI.RFC3161"));
options.setPreferredSignatureSize(12_000);
document.addSignature(signature, timestamper, options);
document.saveIncremental(output);
}
}
}
signWithCallback and signExternally differ only in who calls sign. The external variant opens the
ExternalSigningSupport and passes its content stream to a SignatureInterface; in a real system that call is the
point at which an HSM, a cloud key service or a signing microservice is used, as described in
Remote, HSM & Deferred Signing. A few rules apply to
both.
-
Call
setSignDateand the other metadata beforeaddSignature; the dictionary is written when the document is saved. -
Never modify the document between
addSignatureand the save, and never reuse thePDDocumentafter the save. -
Do not reuse a
PDSignaturefor a second signature. -
A signature on a file that already has signatures needs
saveIncrementaland neversave, and the previous signatures are not broken because the original bytes are preserved. The run for this page signed an already signed invoice a second time and read back two valid signatures with byte ranges0, 1187, 37189, 779and0, 38444, 74446, 806.
The official examples are in the PDFBox repository:
CreateSignature.java
for the callback and
CreateVisibleSignature2.java
for the external path with a visible signature. The artifact org.apache.pdfbox:pdfbox-examples was not published for
3.0.8 (the newest version on Maven Central as of 2026-10-09 was 3.0.7), so these classes are source to read and copy, not a
dependency to add.
Visible signatures
A visible signature is a signature field whose widget annotation has an appearance stream. PDFBox 3 has no ready-made
designer class for it: the example builds a template PDF, a one-page document that holds only the signature field and
its appearance, and hands it to SignatureOptions.setVisualSignature. PDFBox merges the widget into the target page and
uses the template’s appearance. The class below draws a light blue box with a line of text; to add a logo, draw a
PDImageXObject with PDPageContentStream.drawImage in the same stream.
package billing;
import java.awt.Color;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Calendar;
import org.apache.pdfbox.Loader;
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.common.PDStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
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.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureOptions;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
public final class VisibleSignature {
private VisibleSignature() {
}
public static void sign(Path in, Path out, SignatureInterface signer) throws IOException {
PDRectangle box = new PDRectangle(72, 72, 220, 60); // x, y, width, height in points
try (PDDocument document = Loader.loadPDF(in.toFile());
OutputStream output = Files.newOutputStream(out)) {
PDSignature signature = new PDSignature();
signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
signature.setSubFilter(PDSignature.SUBFILTER_ETSI_CADES_DETACHED);
signature.setName("Bookshelf Billing Signer");
signature.setReason("Invoice approval");
signature.setSignDate(Calendar.getInstance());
try (InputStream template = template(document, box, "Signed by Bookshelf Billing Signer");
SignatureOptions options = new SignatureOptions()) {
options.setVisualSignature(template);
options.setPage(0);
options.setPreferredSignatureSize(18_000);
document.addSignature(signature, signer, options);
document.saveIncremental(output);
}
}
}
/** One-page PDF holding only the signature widget and its appearance stream. */
private static InputStream template(PDDocument source, PDRectangle box, String text) throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(source.getPage(0).getMediaBox()));
PDAcroForm acroForm = new PDAcroForm(doc);
doc.getDocumentCatalog().setAcroForm(acroForm);
PDSignatureField field = new PDSignatureField(acroForm);
PDAnnotationWidget widget = field.getWidgets().get(0);
acroForm.setSignaturesExist(true);
acroForm.setAppendOnly(true);
acroForm.getCOSObject().setDirect(true);
acroForm.getFields().add(field);
widget.setRectangle(box);
PDFormXObject form = new PDFormXObject(new PDStream(doc));
form.setResources(new PDResources());
form.setFormType(1);
form.setBBox(new PDRectangle(box.getWidth(), box.getHeight()));
PDAppearanceDictionary appearance = new PDAppearanceDictionary();
appearance.getCOSObject().setDirect(true);
PDAppearanceStream stream = new PDAppearanceStream(form.getCOSObject());
appearance.setNormalAppearance(stream);
widget.setAppearance(appearance);
try (PDPageContentStream cs = new PDPageContentStream(doc, stream)) {
cs.setNonStrokingColor(new Color(235, 242, 250));
cs.addRect(0, 0, box.getWidth(), box.getHeight());
cs.fill();
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD), 10);
cs.setNonStrokingColor(Color.BLACK);
cs.newLineAtOffset(8, box.getHeight() - 18);
cs.showText(text);
cs.endText();
}
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
doc.save(bytes);
return new ByteArrayInputStream(bytes.toByteArray());
}
}
}
Four details are easy to get wrong. The rectangle is in PDF user space, that is, from the bottom-left corner of the
page in points, and the example’s PDRectangle(72, 72, 220, 60) is therefore a box one inch from the left and bottom
edges. The template page must have the same media box as the target page, which is why the code copies it. The form
carries setAppendOnly(true) and setSignaturesExist(true), as in the official example, to mark the file as one that
has signatures.
And the image must be drawn into the appearance stream, not onto the page: content drawn on the page changes the bytes
that the signature covers and has to be added before signing. After running the class the file was rendered with PDFBox’s
own PDFRenderer, and the box appeared at the bottom left with its text.
The official example, which also supports an existing empty signature field, a certification level and text from the
certificate, is
CreateVisibleSignature2.java.
Its companion CreateEmptySignatureForm creates a field to be signed later, the PDFBox counterpart of the iText
signature field described in Signing PDFs with iText.
Timestamps: signature and document
A timestamp is an RFC 3161 token issued by a time stamping authority, which proves that a piece of data existed at a given
time. PAdES uses it in two places, and PDFBox supports both through the same SignatureInterface.
-
A signature timestamp is a token computed over the signature value and stored in the unsigned attributes of the CMS container. It turns a B-B signature into B-T. It was added by the
CmsSignerclass above when aTsaClientis given. -
A document timestamp is a separate signature dictionary with
/Type /DocTimeStampand SubFilterETSI.RFC3161, whose/Contentsis the timestamp token itself, computed over the byte range of the whole file. It carries no certificate of the signer, it covers every earlier signature, and it is the building block of B-LTA. Document timestamps are specified by ETSI TS 119 142-3, and later ones can be added over time.
The client below is minimal and does not hide the protocol: it builds a TimeStampRequest for the SHA-256 digest of the
data with a random nonce and certReq set (so the TSA’s certificate is returned inside the token), posts it with
Content-Type: application/timestamp-query, checks the response against the request and returns the encoded token. It
sends no credentials, and the only server used is the free test TSA at https://freetsa.org/tsr, which is not for
production use; a commercial TSA, with a service level and a published policy, is described in
PAdES Baseline Levels.
package billing;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.math.BigInteger;
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
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;
/** A minimal RFC 3161 client: one POST of a timestamp query, no credentials. */
public final class TsaClient {
private final URI tsaUrl;
private final HttpClient http = HttpClient.newHttpClient();
public TsaClient(String tsaUrl) {
this.tsaUrl = URI.create(tsaUrl);
}
/** Returns the DER encoded TimeStampToken for the SHA-256 digest of the data. */
public byte[] timestamp(byte[] data) throws IOException {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(data);
TimeStampRequestGenerator generator = new TimeStampRequestGenerator();
generator.setCertReq(true);
TimeStampRequest request = generator.generate(
NISTObjectIdentifiers.id_sha256, digest, new BigInteger(64, new SecureRandom()));
HttpRequest post = HttpRequest.newBuilder(tsaUrl)
.header("Content-Type", "application/timestamp-query")
.POST(HttpRequest.BodyPublishers.ofByteArray(request.getEncoded()))
.build();
HttpResponse<byte[]> reply = http.send(post, HttpResponse.BodyHandlers.ofByteArray());
if (reply.statusCode() != 200) {
throw new IOException("TSA answered HTTP " + reply.statusCode());
}
TimeStampResponse response = new TimeStampResponse(reply.body());
response.validate(request);
TimeStampToken token = response.getTimeStampToken();
return token.getEncoded();
} catch (NoSuchAlgorithmException | TSPException e) {
throw new IOException(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException(e);
}
}
}
The document timestamp is added with an ordinary addSignature call and a SignatureInterface that hands the covered
bytes to the TSA, which is the method addDocumentTimestamp of PdfBoxSigning above. The two official examples
are CreateSignedTimeStamp.java
(a document timestamp, as here) and
CreateEmbeddedTimeStamp.java
(adding a timestamp token to the unsigned attributes of an existing signature, rewriting its container in place).
The protocol is defined in RFC 3161.
The driver runs all of them. It creates the invoice, signs it three ways, then signs with a timestamp and appends a document timestamp.
package billing;
import java.nio.file.Files;
import java.nio.file.Path;
public final class PdfBoxDemo {
public static void main(String[] args) throws Exception {
Path dir = Path.of("out");
Files.createDirectories(dir);
Path invoice = dir.resolve("invoice.pdf");
Invoice.create(invoice, "2026-0001");
SigningMaterial material = SigningMaterial.load(Path.of("billing-signer.p12"));
CmsSigner baseline = new CmsSigner(material, null);
PdfBoxSigning.signWithCallback(invoice, dir.resolve("invoice-callback.pdf"), baseline);
PdfBoxSigning.signExternally(invoice, dir.resolve("invoice-external.pdf"), baseline);
VisibleSignature.sign(invoice, dir.resolve("invoice-visible.pdf"), baseline);
TsaClient tsa = new TsaClient("https://freetsa.org/tsr");
PdfBoxSigning.signWithCallback(invoice, dir.resolve("invoice-bt.pdf"), new CmsSigner(material, tsa));
PdfBoxSigning.addDocumentTimestamp(dir.resolve("invoice-bt.pdf"), dir.resolve("invoice-bt-doc.pdf"), tsa);
}
}
Validation data for B-LT with AddValidationInformation
B-T proves when the signature was made. B-LT also stores what was known about the certificates at that time: the
chain, the OCSP responses (RFC 6960) and the CRLs, in the document security store, the /DSS dictionary of the catalog,
optionally with a /VRI entry per signature. PDFBox has no public API for this. It has a source example,
AddValidationInformation.java,
that opens a signed file, collects the chain of the last signature (and of its timestamp), downloads an OCSP response
or a CRL for each certificate from the addresses written in the certificates, and writes them into /DSS, with the
/VRI entry, in an incremental update. It needs helper classes of the same package (CertInformationCollector,
CertInformationHelper, OcspHelper, CRLVerifier and others) that must be copied together with it.
The example was copied from the 3.0.8 tag and run on the signed and timestamped file of the previous section, which
produced a file with a /DSS dictionary holding certificates, one OCSP response, one CRL and a /VRI entry. Three
observations matter in practice.
-
With Bouncy Castle 1.86 on the classpath, the example failed with
IOException reading content.: Extra data detected in stream, because it passes the zero-padded/Contentsreturned byPDSignature.getContentsstraight tonew CMSSignedData(byte[]), and the parser of 1.86 apparently refuses the trailing bytes, which the 1.84 parser accepted. With Bouncy Castle 1.84, the version that PDFBox 3.0.8 declares in its own build, it ran. Either pin 1.84 for this tool, or trim the container to its first ASN.1 object before parsing, as the checking helper described near the end of this page does with anASN1InputStream. -
The example fetched revocation data from the addresses in the certificates it found. The test signer has no OCSP or CRL address, so the OCSP response and the CRL in the output most likely belong to the free TSA’s certificate; a validator would still miss revocation data for the signer. For a real B-LT the signer certificate must come from a CA that publishes OCSP or CRL addresses.
-
The example logged an OCSP error for the TSA certificate (the responder answered "status unknown") and still wrote the file. It reports errors in the log and does not stop, so a build pipeline must check the output.
Because of the first and third points, treat AddValidationInformation as a starting point to adapt, not as a library
call. If the document must reach B-LT or B-LTA, the EU DSS service of the next sections does the same job with a
supported API, a verifier that you configure, and PDFBox underneath.
EU DSS: the PAdES service
DSS separates what to sign from how the key signs. A PAdESService is configured with a CertificateVerifier, which
knows the trusted certificates and the sources of revocation data, and optionally a TSPSource for timestamps. The
caller creates PAdESSignatureParameters that describe the signature, and then signs in three steps: getDataToSign returns
the ToBeSigned bytes, a signature token signs them, and signDocument embeds the SignatureValue and returns the new
document. The three-step shape is deliberate: the middle step can run on another machine or in a hardware device, which
is what Remote, HSM & Deferred Signing relies on.
The classes that matter, all of DSS 6.5 as compiled:
| Class | Role |
|---|---|
|
Creates, extends and timestamps PAdES documents. |
|
Signature level, digest algorithm, signing certificate and chain, signing date through |
|
|
|
A |
|
Timestamp client for a TSA URL; with |
|
The verifier: trusted certificate sources, |
|
PDF layer using PDFBox, with the "native drawer" that writes visible text as real text. |
DSS documentation states that its default is already the native drawer, and that setPdfObjFactory(new
PdfBoxNativeObjectFactory()) or a META-INF/services/eu.europa.esig.dss.pdf.IPdfObjFactory file selects it explicitly. The
alternative, the original drawer, rasterises text into an image, which makes it unsearchable. The class below is the full
service of the page: the constructor builds the verifier and the service, and four methods cover B-B/B-T signing, a
visible signature, extension and a standalone document timestamp. The comments mark the three steps of the signing flow.
package billing;
import eu.europa.esig.dss.alert.LogOnStatusAlert;
import eu.europa.esig.dss.enumerations.DigestAlgorithm;
import eu.europa.esig.dss.enumerations.SignatureLevel;
import eu.europa.esig.dss.model.DSSDocument;
import eu.europa.esig.dss.model.FileDocument;
import eu.europa.esig.dss.model.SignatureValue;
import eu.europa.esig.dss.model.ToBeSigned;
import eu.europa.esig.dss.pades.PAdESSignatureParameters;
import eu.europa.esig.dss.pades.PAdESTimestampParameters;
import eu.europa.esig.dss.pades.SignatureFieldParameters;
import eu.europa.esig.dss.pades.SignatureImageParameters;
import eu.europa.esig.dss.pades.SignatureImageTextParameters;
import eu.europa.esig.dss.pades.signature.PAdESService;
import eu.europa.esig.dss.pdf.pdfbox.PdfBoxNativeObjectFactory;
import eu.europa.esig.dss.service.http.commons.TimestampDataLoader;
import eu.europa.esig.dss.service.tsp.OnlineTSPSource;
import eu.europa.esig.dss.spi.DSSUtils;
import eu.europa.esig.dss.spi.validation.CertificateVerifier;
import eu.europa.esig.dss.spi.validation.CommonCertificateVerifier;
import eu.europa.esig.dss.spi.x509.CommonTrustedCertificateSource;
import eu.europa.esig.dss.spi.x509.revocation.crl.ExternalResourcesCRLSource;
import eu.europa.esig.dss.token.DSSPrivateKeyEntry;
import eu.europa.esig.dss.token.Pkcs12SignatureToken;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyStore.PasswordProtection;
import java.util.Date;
import org.bouncycastle.operator.OperatorCreationException;
public final class DssSigning implements AutoCloseable {
private final Pkcs12SignatureToken token;
private final DSSPrivateKeyEntry signerEntry;
private final PAdESService service;
public DssSigning(Path signerP12, Path caPem, Path caP12, String tsaUrl)
throws IOException, GeneralSecurityException, OperatorCreationException {
char[] storePassword = System.getenv("BILLING_KEYSTORE_PASSWORD").toCharArray();
token = new Pkcs12SignatureToken(signerP12.toFile(), new PasswordProtection(storePassword));
signerEntry = token.getKey("billing-signer");
// Trust anchor: the test CA exported by the keytool script
CommonTrustedCertificateSource trusted = new CommonTrustedCertificateSource();
trusted.addCertificate(DSSUtils.loadCertificate(caPem.toFile()));
CertificateVerifier verifier = new CommonCertificateVerifier();
verifier.setTrustedCertSources(trusted);
// Test PKI only: the signer certificate has no CRL or OCSP address, so hand DSS a CRL
verifier.setCrlSource(new ExternalResourcesCRLSource(new ByteArrayInputStream(TestCrl.issue(caP12))));
// Log, instead of failing, when the TSA's own chain has no revocation data
verifier.setAlertOnMissingRevocationData(new LogOnStatusAlert());
service = new PAdESService(verifier);
service.setPdfObjFactory(new PdfBoxNativeObjectFactory()); // write the PDF with PDFBox
if (tsaUrl != null) {
service.setTspSource(new OnlineTSPSource(tsaUrl, new TimestampDataLoader()));
}
}
private PAdESSignatureParameters parameters(SignatureLevel level) {
PAdESSignatureParameters parameters = new PAdESSignatureParameters();
parameters.setSignatureLevel(level);
parameters.setDigestAlgorithm(DigestAlgorithm.SHA256);
parameters.setSigningCertificate(signerEntry.getCertificate());
parameters.setCertificateChain(signerEntry.getCertificateChain());
parameters.bLevel().setSigningDate(new Date());
parameters.setReason("Invoice approval");
parameters.setLocation("Pamplona");
parameters.setContentSize(18_000); // bytes reserved for /Contents
return parameters;
}
/** PAdES_BASELINE_B signs; PAdES_BASELINE_T also asks the TSP source for a signature timestamp. */
public void sign(Path in, Path out, SignatureLevel level) throws IOException {
sign(new FileDocument(in.toFile()), parameters(level), out);
}
/** Adds a visible appearance: a rectangle on page 1 with text, no image. */
public void signVisible(Path in, Path out) throws IOException {
PAdESSignatureParameters parameters = parameters(SignatureLevel.PAdES_BASELINE_T);
SignatureFieldParameters field = new SignatureFieldParameters();
field.setPage(1);
field.setOriginX(72);
field.setOriginY(640); // DSS measures from the top left corner of the page
field.setWidth(220);
field.setHeight(60);
SignatureImageTextParameters text = new SignatureImageTextParameters();
text.setText("Signed by Bookshelf Billing Signer");
SignatureImageParameters image = new SignatureImageParameters();
image.setFieldParameters(field);
image.setTextParameters(text);
parameters.setImageParameters(image);
sign(new FileDocument(in.toFile()), parameters, out);
}
private void sign(DSSDocument document, PAdESSignatureParameters parameters, Path out) throws IOException {
ToBeSigned toBeSigned = service.getDataToSign(document, parameters); // 1. what to sign
SignatureValue value = token.sign(toBeSigned, parameters.getDigestAlgorithm(), signerEntry); // 2. sign it
try (OutputStream output = Files.newOutputStream(out)) {
service.signDocument(document, parameters, value).writeTo(output); // 3. embed it
}
}
/** Raises an existing signature: T to LT, LT to LTA. */
public void extend(Path in, Path out, SignatureLevel target) throws IOException {
DSSDocument document = new FileDocument(in.toFile());
try (OutputStream output = Files.newOutputStream(out)) {
service.extendDocument(document, parameters(target)).writeTo(output);
}
}
/** A standalone document timestamp: a new revision, no existing signature is touched. */
public void timestamp(Path in, Path out) throws IOException {
DSSDocument document = new FileDocument(in.toFile());
try (OutputStream output = Files.newOutputStream(out)) {
service.timestamp(document, new PAdESTimestampParameters()).writeTo(output);
}
}
@Override
public void close() {
token.close();
}
}
Several choices in the constructor are specific to a test PKI, and a production service would differ.
-
Trust.
CommonTrustedCertificateSourcetakes the certificates that are trusted anchors; here, the test CA. In production the trusted certificates come from the EU List of Trusted Lists (LOTL) throughTrustedListsCertificateSourceand aTLValidationJob, as explained in Signature Validation & Long-Term Validity, and from the Adobe AATL for documents read in Acrobat. -
Revocation. Real certificates carry OCSP and CRL addresses, and DSS fetches the data from them only when the verifier has online sources:
CommonCertificateVerifiercreates none by default, so a production service setsOnlineOCSPSource,OnlineCRLSource(both indss-service) andDefaultAIASourceexplicitly withsetOcspSource,setCrlSourceandsetAIASource. The test signer has no such addresses, so the class issues a CRL from the test CA with a short Bouncy Castle class (TestCrl, below) and passes it as anExternalResourcesCRLSource. -
Alerts. By default, DSS refuses to extend a signature to LT when revocation data is missing for any certificate in the chain, throwing
Revocation data is missing for one or more certificate(s). The first attempt on this page failed with that message for both the signer certificate and the TSA’s own certificate. The code replaces the alert withLogOnStatusAlert, which logs a warning and goes on; that is acceptable for a demonstration and wrong for production, where the exception is exactly what protects you from producing a file labelled B-LT that cannot be validated.
The CRL helper issues an empty list signed by the test CA, valid for a week.
package billing;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;
import java.util.Date;
import org.bouncycastle.cert.X509CRLHolder;
import org.bouncycastle.cert.X509v2CRLBuilder;
import org.bouncycastle.cert.jcajce.JcaX509CRLConverter;
import org.bouncycastle.cert.jcajce.JcaX509v2CRLBuilder;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
/** Issues an empty CRL from the test CA, so DSS has revocation data for the signer certificate. */
public final class TestCrl {
private TestCrl() {
}
public static byte[] issue(Path caP12) throws IOException, GeneralSecurityException, OperatorCreationException {
char[] storePassword = System.getenv("BILLING_KEYSTORE_PASSWORD").toCharArray();
KeyStore store = KeyStore.getInstance("PKCS12");
try (InputStream in = Files.newInputStream(caP12)) {
store.load(in, storePassword);
}
PrivateKey caKey = (PrivateKey) store.getKey("billing-test-ca", storePassword);
X509Certificate ca = (X509Certificate) store.getCertificate("billing-test-ca");
Date now = new Date();
X509v2CRLBuilder builder = new JcaX509v2CRLBuilder(ca, now);
builder.setNextUpdate(new Date(now.getTime() + 7L * 24 * 3600 * 1000));
X509CRLHolder crl = builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(caKey));
return new JcaX509CRLConverter().getCRL(crl).getEncoded();
}
}
The driver signs the invoice at B-B and B-T, extends the B-T file to B-LT and then B-LTA, adds a standalone document timestamp, and signs a visible version. Each of the DSS operations is an ordinary call and none of them modifies the source file.
package billing;
import eu.europa.esig.dss.enumerations.SignatureLevel;
import java.nio.file.Path;
public final class DssDemo {
public static void main(String[] args) throws Exception {
Path out = Path.of("out");
String tsa = "https://freetsa.org/tsr";
try (DssSigning dss = new DssSigning(Path.of("billing-signer.p12"), Path.of("billing-test-ca.pem"),
Path.of("billing-test-ca.p12"), tsa)) {
Path invoice = out.resolve("invoice.pdf");
dss.sign(invoice, out.resolve("dss-b.pdf"), SignatureLevel.PAdES_BASELINE_B);
dss.sign(invoice, out.resolve("dss-t.pdf"), SignatureLevel.PAdES_BASELINE_T);
dss.extend(out.resolve("dss-t.pdf"), out.resolve("dss-lt.pdf"), SignatureLevel.PAdES_BASELINE_LT);
dss.extend(out.resolve("dss-lt.pdf"), out.resolve("dss-lta.pdf"), SignatureLevel.PAdES_BASELINE_LTA);
dss.timestamp(out.resolve("dss-t.pdf"), out.resolve("dss-t-doc.pdf"));
dss.signVisible(invoice, out.resolve("dss-visible.pdf"));
}
}
}
The levels map to the standard as follows, and the next diagram shows what each step adds to the file.
The signing and extension API derives from the DSS documentation sections PAdES and Timestamping a PDF, and the classes are documented in the DSS API documentation.
Extending signatures
extendDocument takes a signed document and parameters whose SignatureLevel is the target. DSS validates the existing
signatures, collects what the target level needs and adds an incremental update: for LT, a /DSS dictionary with the
chain, the CRLs and the OCSP responses (without /VRI unless setIncludeVRIDictionary(true) is called, which DSS
documents as not to be used for baseline signatures); for LTA, a document timestamp over the whole file. In the run on this page the results were read back with PDFBox:
| File | Signatures in the file | /DSS dictionary |
|---|---|---|
B-B |
One |
None |
B-T |
One signature with a signature timestamp in its unsigned attributes |
None |
B-LT |
The same signature |
Certificates (4 entries) and CRLs (1 entry) |
B-LTA |
The signature and one |
The same as B-LT |
The document timestamp of the B-LTA file used SHA-512 for its message imprint in this run, while
the signature timestamp that this page’s own TsaClient requests uses SHA-256; the imprint of both matched a digest
recomputed over the signed byte range. Run-time warnings must not be ignored: DSS logged that it found no revocation data
for the TSA certificate chain, because that chain is not trusted by the verifier and DSS skips revocation checks for
untrusted chains. For this reason the B-LT and B-LTA files above show the shape of the levels and exercise the API;
they are not valid long-term signatures in the sense of the standard, a point taken up again in the last-but-one section
of this page.
Renewing a B-LTA file years later means adding a new document timestamp over the previous state, which is what the
iText method prolongSignatures does in Signing PDFs with iText;
the DSS documentation describes the corresponding extension, and this page did not run it. The standalone timestamp method of the
service adds a document timestamp to any PDF, signed or not, without touching its signatures, and can therefore be used
alone as an inexpensive proof of existence for unsigned archives. The DSS documentation explains that a standalone
timestamp is used on purpose, because adding a token to the CMS signature of PDF would break existing signatures.
A visible signature with DSS
PAdESSignatureParameters.setImageParameters takes SignatureImageParameters, which holds a
SignatureFieldParameters (page, origin, width, height, optional field id) and either text parameters, an image, or
both. signVisible above draws a text box at the left of the first page. Unlike PDFBox, DSS measures the origin from the
top-left corner of the page, with y growing downwards, and it builds the field and its appearance itself with the
native drawer, which the DSS documentation says writes the text as real, searchable text. Rendering the output with
PDFBox showed the line of text near the bottom left of the page, where originY of 640 points from the top puts it;
searchability was not tested.
Two behaviours are worth knowing. The file is much larger than the invisible one (156,209 bytes against 37,933): the
last number of the /ByteRange of the visible file, which is the length of everything after /Contents, is 119,038
(0, 1169, 37171, 119038), because the native drawer embeds a font for the text. And when the file already has several signature fields, DSS can sign an existing empty
field by naming it in SignatureFieldParameters.setFieldId.
Signing JasperReports output
JasperReports has no signing feature. The PDF exporter writes the document through OpenPDF in jasperreports-pdf, and
its configuration (see PDF Exporter Configuration)
offers encryption, PDF/A and metadata, but no signature, and the books on the library that were consulted
for this section do not mention PDF/A, tagging, forms or signing at all (finding O27). The reliable pattern is to
export to bytes, which is JasperExportManager.exportReportToPdf(JasperPrint), and then to sign the bytes with
any of the libraries on this page or on the iText page. Signing always follows the export, because the exporter writes the
whole file in one go and a signature cannot be part of it; the signature is an incremental update that covers
the exported file exactly as it was.
<dependency>
<groupId>net.sf.jasperreports</groupId>
<artifactId>jasperreports</artifactId>
<version>7.0.8</version>
</dependency>
<dependency>
<groupId>net.sf.jasperreports</groupId>
<artifactId>jasperreports-pdf</artifactId>
<version>7.0.8</version>
</dependency>
The report is a minimal JasperReports 7 design, with the namespace-free <element kind="..."> syntax of the v7 format,
stored as src/main/resources/reports/invoice.jrxml:
<jasperReport name="invoice" language="java" pageWidth="595" pageHeight="842"
columnWidth="515" leftMargin="40" rightMargin="40" topMargin="40" bottomMargin="40">
<parameter name="NUMBER" class="java.lang.String"/>
<title height="60">
<element kind="textField" x="0" y="0" width="515" height="30" fontSize="18" bold="true">
<expression><![CDATA["Bookshelf invoice " + $P{NUMBER}]]></expression>
</element>
</title>
</jasperReport>
The class compiles the report, fills it with one empty row, exports to bytes, writes the unsigned file and signs it
with the PdfBoxSigning class of this page. Two details were found by running it: the parameter map given to
fillReport must be mutable, because the engine adds its own parameters to it, and Map.of fails with
UnsupportedOperationException; and the exporter’s output is an ordinary PDF that PDFBox loads and signs without
any configuration.
package billing;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.util.HashMap;
import java.util.Map;
import net.sf.jasperreports.engine.JREmptyDataSource;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JasperCompileManager;
import net.sf.jasperreports.engine.JasperExportManager;
import net.sf.jasperreports.engine.JasperFillManager;
import net.sf.jasperreports.engine.JasperPrint;
import net.sf.jasperreports.engine.JasperReport;
/** JasperReports 7.0.8 has no signing: export to bytes first, then sign the bytes. */
public final class JasperThenSign {
public static void main(String[] args) throws IOException, JRException, GeneralSecurityException {
byte[] pdf;
try (InputStream jrxml = JasperThenSign.class.getResourceAsStream("/reports/invoice.jrxml")) {
JasperReport report = JasperCompileManager.compileReport(jrxml);
Map<String, Object> parameters = new HashMap<>(); // must be mutable: the engine adds to it
parameters.put("NUMBER", "2026-0002");
JasperPrint print = JasperFillManager.fillReport(report, parameters, new JREmptyDataSource(1));
pdf = JasperExportManager.exportReportToPdf(print);
}
Path unsigned = Path.of("out", "jasper-unsigned.pdf");
Files.write(unsigned, pdf);
SigningMaterial material = SigningMaterial.load(Path.of("billing-signer.p12"));
PdfBoxSigning.signWithCallback(unsigned, Path.of("out", "jasper-signed.pdf"), new CmsSigner(material, null));
}
}
The result was read back: one valid ETSI.CAdES.detached signature with byte range 0, 1393, 37395, 926. The same bytes
can be given to DSS with new InMemoryDocument(bytes) instead of a FileDocument, so that nothing touches the disk. A
few consequences of signing after the export:
-
Encrypt before signing if both are needed, and never encrypt after: a signed file that is rewritten is no longer the file that was signed. The encryption options of the exporter and iText’s are in PDF Exporter Configuration and Encryption & Permissions.
-
A file exported as PDF/A stays PDF/A after an incremental signature only if the signature dictionary and the optional appearance meet the conformance level; check the result with a validator, as in PDF Standards & Conformance.
-
JasperReports form fields exported with the AcroForm properties and a signature are different matters: the signature field is added at signing time, not by the exporter.
-
For a monthly sales report produced in a batch, sign once at the end of the pipeline, and keep the signing key behind the service of Remote, HSM & Deferred Signing. Returning the signed bytes from a Spring Boot controller is described in JasperReports with Spring Boot.
Reading back what was signed
Every file of this page was checked after it was written, with a small helper that is not reproduced here: for each
signature dictionary it read /ByteRange, recomputed the covered bytes with PDSignature.getSignedContent, parsed
/Contents with Bouncy Castle (reading only the first ASN.1 object, to ignore the zero padding), verified the signer
with JcaSimpleSignerInfoVerifierBuilder, looked for the signature timestamp attribute, and, for document
timestamps, recomputed the digest of the byte range with the algorithm named in the token and compared it with the
message imprint. This proves that the container matches the bytes and that the timestamp matches too. It does not prove
that anyone trusts the signer, which is validation and not signing; that is the subject of
Signature Validation & Long-Term Validity,
and of the DSS validation module that is not used on this page.
What was run, and what was not
All the Java classes on this page were compiled with Maven on Java 21 against PDFBox 3.0.8, DSS 6.5, Bouncy Castle 1.86
and JasperReports 7.0.8, and run on 2026-10-09 in a scratch project outside the repository, with the test keystores
produced by the keytool script of this page.
| Step | Result |
|---|---|
PDFBox callback signature |
Signed, CMS verified, SubFilter |
PDFBox external signature ( |
Signed, CMS verified |
PDFBox visible signature |
Signed, CMS verified, rendered with PDFBox and the box was visible |
PDFBox signature timestamp (B-T) against |
Token present in the unsigned attributes, signature verified |
PDFBox document timestamp |
Token imprint matched the digest of its byte range |
Second PDFBox signature on a signed file |
Two valid signatures |
|
Failed with Bouncy Castle 1.86, ran with 1.84 and wrote |
DSS B-B, B-T, B-LT, B-LTA, standalone timestamp, visible signature |
All produced; |
JasperReports 7.0.8 export then PDFBox signature |
One valid signature |
The limits of the run must be stated plainly. The TSA was reachable and served time stamp requests; an HTTP HEAD
request to freetsa.org returned HTTP 403 in the same session while the POST of a real query worked, so a curl -I is not a
health check. The test PKI has no OCSP responder and no real CRL distribution point, so no real OCSP or CRL fetching was
exercised for the signer; DSS received a CRL that this page generated. The B-LT and B-LTA files therefore illustrate the
structure of the levels and are not valid long-term signatures: DSS logged missing revocation data for the TSA chain, and
the verifier was configured to log rather than to fail. None of the files was opened in Acrobat, in the DSS demo
application or in veraPDF, and nobody trusts the test CA, so Acrobat would show the signatures as untrusted. A real
B-LT claim needs a real certificate chain, real revocation endpoints and a validation with a tool that checks both.
Production notes
-
Keep the private key out of the application where you can.
Pkcs12SignatureTokenis for tests and small deployments; DSS offersPkcs11SignatureTokenfor HSMs and smart cards, andSignatureTokenConnectioncan be implemented to call a cloud key service, as shown in Remote, HSM & Deferred Signing and, for the cloud side, in AWS Secrets Manager & KMS, Azure Key Vault & Secrets and Google Cloud Secret Manager & KMS. -
Read the keystore password from the environment or the secret manager of the platform, never from source, and never log it.
-
Reserve enough space. A container that does not fit fails; one that is much too large wastes size in every file. The
contentSizeof DSS andsetPreferredSignatureSizeof PDFBox are both estimates to be measured for your chain, and they must be raised when timestamps and several certificates are embedded. -
Build the DSS service once at start-up and reuse it, and keep the expensive part, the trusted list validation, in a scheduled job and not in a request. Check the DSS documentation before sharing one service between threads; this page did not test that.
-
Treat a timestamp server as a dependency: set time-outs, retry, and fall back to the next TSA, because a signing call that waits for an unavailable TSA blocks the invoice run.
-
Choose algorithms deliberately. The example uses RSA 3072 with SHA-256; ETSI TS 119 312 V2.1.1 recommends RSA-PSS or ECDSA, and DSS selects the encryption algorithm from the key and the digest from
setDigestAlgorithm. -
Use
saveIncremental, neversave, for a file that is already signed. -
Do not assume that a signature that a viewer shows as valid is long-term valid: a viewer may fetch revocation data online when it opens the file, and a file that relies on that does not carry it, so it is not B-LT.
Outdated points to avoid
-
Code from the PDFBox 2.0 era:
PDDocument.load(file)isLoader.loadPDF(file)in 3.0;PDFontconstants such asPDType1Font.HELVETICAare constructed fromStandard14Fonts.FontName(new PDType1Font(Standard14Fonts.FontName.HELVETICA)); the designer classes of the 2.0 visible-signature examples are not what the official 3.0 example uses, which builds a template PDF as shown above. -
The old PDFBox
SignatureInterfaceexamples that useadbe.pkcs7.sha1, SHA-1 digests orSHA1withRSA(finding O11 of this section’s source analysis): SHA-1 is forbidden for new signatures, andadbe.pkcs7.sha1is deprecated. Use SHA-256 or better, andETSI.CAdES.detachedfor PAdES. -
Using the signature algorithm of the certificate (
getSigAlgName) as the algorithm of the signer without thinking: it is the issuer’s algorithm. -
Signing with a CMS container that lacks
signing-certificate-v2and calling the result PAdES. -
Believing that PDFBox signs at a PAdES level by itself: it writes the file; the level is determined by what you put in the container and the document security store.
-
Believing that JasperReports can sign: it cannot; export first, then sign the bytes (finding O27).
-
Using
org.apache.pdfbox:pdfbox-examplesas a dependency: it was not published for 3.0.8. -
Treating a DSS extension with
LogOnStatusAlertas a successful B-LT: the alert is a demonstration setting. -
Using the free test TSA, or any server without a service agreement, for production signatures.
-
Old tutorials with iText 1 or 2 signing code (
PdfStamper.createSignature), found in books such as iText in Action, which was consulted and is not reproduced: it targets a different, removed API.
References
-
Apache PDFBox, project site and the migration guide to 3.0.
-
PDFBox javadoc, SignatureInterface, 3.0.8.
-
PDFBox signing examples, CreateSignature, CreateVisibleSignature2, CreateSignedTimeStamp, CreateEmbeddedTimeStamp and AddValidationInformation.
-
EU DSS, DSS documentation (sections PAdES and Timestamping a PDF), API documentation and source repository (LGPL-2.1).
-
RFC documents: RFC 3161 (time-stamp protocol), RFC 5035 (ESS signing certificate v2) and RFC 6960 (OCSP).
-
Licences, Apache-2.0 and GNU LGPL 2.1; iText is AGPLv3 or commercial, AGPLv3 licence.
-
Oracle, keytool in JDK 21.
-
Free test TSA, freetsa.org (test use only).
-
In this section: Digital Signatures: Fundamentals, PAdES Baseline Levels, Signing PDFs with iText, Remote, HSM & Deferred Signing, Signature Validation & Long-Term Validity, PDF Exporter Configuration, JasperReports with Spring Boot and Encryption & Permissions.