Remote, HSM & Deferred Signing
|
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. |
Every signing example in the earlier pages of this section loads a private key from a PKCS#12 file into the JVM. That is fine for a test, and it is the weakest part of a production design: whoever can read the file, or dump the process memory, owns the key and can sign anything in the company’s name. Regulated signatures make it worse, because a qualified signature or a qualified seal has to be created inside a certified device. The remedy is always the same: the key stays inside a device (a hardware security module, a smart card, a cloud key management service, a remote signing service) and the application sends it only the small thing it wants signed, which is a digest. This page shows how to build that with iText 9.8, how the deferred or two-phase signing API splits the work so that the key holder does not need the document, and where PDFBox and EU DSS fit. Versions are stated as of 2026-10-09: iText Core 9.8.0 (AGPLv3 or commercial licence), EU DSS 6.5 (LGPL-2.1) and Java 21.
The page builds on Digital Signature Fundamentals
(the signature dictionary, /ByteRange, CMS and the test PKI), on
PAdES Baseline Levels (what B-B, B-T, B-LT and B-LTA mean) and
on Signing PDFs with iText (the one-step API with a local key).
The PDFBox and DSS equivalents of the one-step flow are in
Signing with Apache PDFBox & EU DSS, and checking the
result is in Signature Validation & Long-Term Validity.
Every iText and DSS example on this page was compiled against exactly those versions and run in a throwaway Maven project, with a local test keystore standing in for the remote signer. The exceptions are stated where they occur: the SunPKCS11, AWS KMS, Azure Key Vault and Google Cloud KMS classes were compiled but not run, because they need a token or a cloud account, and the CSC client was run against a mock server that was written for the test, not against a real provider.
Where the key lives and what crosses the wire
Remote signing is a family of designs that differ in where the key is and in who talks to it. What they share is the shape of the call: a digest goes in, a signature value comes out.
| Design | Where the key lives | What your code sends |
|---|---|---|
PKCS#11 token (HSM, smart card, national eID) |
Inside the device, reached through a vendor library on the same host or through a network HSM client. |
The digest, through the JCA |
Cloud KMS (AWS KMS, Azure Key Vault or Managed HSM, Google Cloud KMS) |
Inside the cloud provider’s HSM-backed service; the key cannot be exported. |
The digest, over HTTPS, authenticated with the workload’s cloud identity. |
Remote signing service (Cloud Signature Consortium API) |
At a trust service provider that manages the user’s credential; used for qualified signatures and seals. |
The Base64 digest, plus an authorisation (a Signature Activation Data) that the signer gave consent. |
External CMS provider (EU DSS |
At the service, which also builds the CMS structure. |
The digest of the PDF byte range, or the data to be signed, depending on who builds the signed attributes. |
Two properties follow, and both are used throughout this page. First, because only a digest travels, the document itself never leaves your service, which matters for confidentiality, for bandwidth and for the size of the request. Second, because the key holder signs a digest and not a document, it cannot know what it signs. The protection against signing the wrong thing therefore has to come from your own code and from the authorisation the service demands, which is the topic of the last sections.
What exactly is signed
A PAdES signature is a CMS SignedData structure (RFC 5652). The signer does not sign the PDF bytes directly. It
signs the DER encoding of the signed attributes, a small set that contains the content type, the SHA-256 digest of
the PDF byte range (messageDigest) and, for PAdES, the digest of the signing certificate (signing-certificate-v2).
There is no CMS signing-time attribute: the PAdES baseline profile forbids it, and the claimed signing time is the
/M entry of the signature dictionary, which is inside the byte range. The key holder signs the digest of that DER
encoding. So the chain is: byte range, then digest A (inside the signed attributes), then digest B of the signed
attributes, then the signature value over digest B.
Only digest B goes to the key holder when it supports "sign a digest" operations, which all the services in this page
do.
Two details decide whether a remote signer works the first time.
-
RSA with PKCS#1 v1.5 signs a
DigestInfo. The value that is encrypted with the private key is the digest wrapped in an ASN.1 structure that names the hash algorithm. A KMS asked forRSASSA_PKCS1_V1_5_SHA_256with a message type of digest builds that structure for you. The local stand-in below has to build it by hand, which is a good way to understand what the services do. -
ECDSA signatures have two encodings. Java and CMS use the DER encoding, a sequence of two integers. Some services return a fixed-length concatenation of the two integers instead, and then the value must be converted to DER before it is embedded. Check the format your service documents; with RSA the question does not arise.
The digest algorithm must be SHA-256 or stronger. This page uses SHA-256 throughout. SHA-1 is not acceptable for new
signatures (ETSI TS 119 312), and it is the algorithm that the 2006 era of the signing API used by default (finding
O11 of this section’s source analysis). The old PdfStamper.createSignature and PdfSignatureAppearance with
setCrypto and setExternalDigest are gone from iText 7 and later, and the sub-filter adbe.pkcs7.sha1 that those
examples relied on is deprecated.
Project setup and a stand-in remote signer
The Maven coordinates for the core examples are the two iText artifacts used in the rest of this section. iText is released under the AGPLv3 or under a commercial licence; if your service is closed source and offered over a network, read the licensing discussion in Introduction & Approaches before you choose.
<dependencies>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itext-core</artifactId>
<version>9.8.0</version>
<type>pom</type>
</dependency>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>bouncy-castle-adapter</artifactId>
<version>9.8.0</version>
</dependency>
</dependencies>
The examples need a signing certificate. The test PKI of this section (a self-signed test root billing-test-ca.p12 and a
signer billing-signer.p12, both generated on the fundamentals page) is reused as it is; the script is in
Digital Signature Fundamentals. As a reminder, the
password comes from the environment and the CA issues the signer certificate with keytool -gencert:
export BILLING_KEYSTORE_PASSWORD=changeit-placeholder # test value only; use a secret store in production
keytool -genkeypair -alias billing-signer -keyalg RSA -keysize 3072 -sigalg SHA256withRSA \
-dname "CN=Bookshelf Billing Signer, O=Bookshelf Test, C=ES" -validity 365 \
-keystore billing-signer.p12 -storetype PKCS12 \
-storepass:env BILLING_KEYSTORE_PASSWORD -keypass: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 -rfc -infile billing-signer.csr -outfile billing-signer.pem
The command lines are the ones of the fundamentals page, with the CA created and the chain imported back into
billing-signer.p12 as shown there. The keytool
reference for JDK 21 documents the options.
To make every example below independent of a cloud account, the application code talks to a small interface,
RemoteSigner, and the examples run against a stand-in that reads the test keystore. The stand-in exposes exactly the
capability of a KMS or an HSM, "sign this digest", so the code around it is the production code, and the real
implementations further down replace only the class that is constructed at start-up.
package com.example.billing.signing;
import java.security.GeneralSecurityException;
import java.security.cert.X509Certificate;
/** What every remote signer has in common: it receives a digest and returns a signature value. */
public interface RemoteSigner {
/** The signer certificate followed by the issuing certificates, leaf first. */
X509Certificate[] certificateChain();
/** Signs a SHA-256 digest with RSASSA-PKCS1-v1_5 and returns the raw signature value. */
byte[] signDigest(byte[] sha256Digest) throws GeneralSecurityException;
}
package com.example.billing.signing;
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.Signature;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import org.bouncycastle.asn1.DERNull;
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.asn1.x509.DigestInfo;
/**
* Stand-in for the remote service in the examples: it exposes only "sign this digest", exactly like a KMS or an
* HSM, but the private key comes from a local test keystore. Never use it in production.
*/
public final class LocalKeystoreRemoteSigner implements RemoteSigner {
private final PrivateKey privateKey;
private final X509Certificate[] chain;
public LocalKeystoreRemoteSigner(Path keystore, char[] storePassword, String alias) throws Exception {
KeyStore ks = KeyStore.getInstance("PKCS12");
try (InputStream in = Files.newInputStream(keystore)) {
ks.load(in, storePassword);
}
this.privateKey = (PrivateKey) ks.getKey(alias, storePassword);
Certificate[] raw = ks.getCertificateChain(alias);
this.chain = new X509Certificate[raw.length];
for (int i = 0; i < raw.length; i++) {
this.chain[i] = (X509Certificate) raw[i];
}
}
@Override
public X509Certificate[] certificateChain() {
return chain.clone();
}
@Override
public byte[] signDigest(byte[] sha256Digest) throws GeneralSecurityException {
try {
// PKCS#1 v1.5 signs a DigestInfo (algorithm identifier + digest); KMS and HSMs add it for you.
byte[] digestInfo = new DigestInfo(
new AlgorithmIdentifier(NISTObjectIdentifiers.id_sha256, DERNull.INSTANCE),
sha256Digest).getEncoded();
Signature signature = Signature.getInstance("NONEwithRSA");
signature.initSign(privateKey);
signature.update(digestInfo);
return signature.sign();
} catch (java.io.IOException e) {
throw new GeneralSecurityException(e);
}
}
}
The stand-in uses the Bouncy Castle DigestInfo class, which the iText adapter already brings in. It must never be
used in production: it is a PKCS#12 file in the JVM, which is the situation this page tries to leave. The interface
is adapted to iText with an IExternalSignature. iText calls sign with the bytes to be signed (the DER-encoded signed
attributes), and the adapter hashes them locally and sends only the digest:
package com.example.billing.signing;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import com.itextpdf.signatures.IExternalSignature;
import com.itextpdf.signatures.ISignatureMechanismParams;
/** Adapts a {@link RemoteSigner} to iText: iText hands over the signed attributes, the service gets only a digest. */
public final class RemoteDigestSignature implements IExternalSignature {
private final RemoteSigner remote;
public RemoteDigestSignature(RemoteSigner remote) {
this.remote = remote;
}
@Override
public String getDigestAlgorithmName() {
return "SHA256";
}
@Override
public String getSignatureAlgorithmName() {
return "RSA";
}
@Override
public ISignatureMechanismParams getSignatureMechanismParameters() {
return null;
}
@Override
public byte[] sign(byte[] message) throws GeneralSecurityException {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(message);
return remote.signDigest(digest);
}
}
getSignatureMechanismParameters returns null because RSASSA-PKCS1-v1_5 has no parameters; a service that signs with
RSASSA-PSS needs the matching ISignatureMechanismParams object instead. The digest name is the iText name SHA256,
not the JCA name SHA-256.
This code derives from the iText API documentation of IExternalSignature and from the Knowledge Base series on signing with AWS KMS.
Two-phase signing
The one-step API of PdfPadesSigner needs the document and the key in the same call. Two-phase signing, also called
deferred or external signing, cuts that call in two so that they can be separated in time, in process and in trust.
-
Phase one, prepare. The signer opens the unsigned PDF in append mode, adds a signature field and a signature dictionary whose
/Contentsis a block of zeros large enough for the future CMS, writes the file, and computes the digest of the two byte ranges around that block. No key is involved, so this phase can run anywhere that has the document. -
The key holder signs. The digest of the signed attributes goes to the HSM, the KMS, the CSC service or the end user’s device, which returns the signature value. This can take milliseconds or, when a human must approve, minutes.
-
Phase two, complete. The signature value is placed into the CMS structure, the CMS is encoded and written into the zero block. No other byte of the file changes, so the byte-range digest stays valid.
The sequence below shows the roles for the Bookshelf Billing invoice. The service never holds the key, the key holder never holds the invoice, and the state that links the two phases is a small record that can be stored anywhere.
Three iText 9 APIs implement this, at different levels.
| API | Use it when |
|---|---|
|
You want a PAdES baseline signature (B-B to B-LTA) and are happy for iText to build the CMS. The recommended path. |
|
You build the CMS yourself, for example because a provider returns a ready CMS or you add custom attributes. It is the building block of the helper. |
|
You have code or a service that turns the byte range into a CMS, or you migrate iText 5 to 8 code that uses
|
The high-level helper
PadesTwoPhaseSigningHelper is the two-phase counterpart of PdfPadesSigner. Its first method,
createCMSContainerWithoutSignature, takes the certificate chain, the digest algorithm name (SHA256), a PdfReader
over the unsigned file, an output stream for the prepared file and SignerProperties. The signature field name is a
property of SignerProperties, not an argument. It returns a CMSContainer that already holds the certificates, the
signing-certificate attribute and the digest of the byte range, but no signature. The CMSContainer can be serialised
with serialize() and read back with new CMSContainer(bytes), which is how the state travels between two HTTP
requests. The second group of methods, signCMSContainerWithBaselineBProfile and its T, LT and LTA siblings, takes the
IExternalSignature, a reader over the prepared file, the output stream, the field name and the container.
package com.example.billing.signing;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.security.cert.Certificate;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.signatures.IExternalSignature;
import com.itextpdf.signatures.PadesTwoPhaseSigningHelper;
import com.itextpdf.signatures.SignerProperties;
import com.itextpdf.signatures.cms.CMSContainer;
/** PAdES B-B in two requests with the high-level helper of iText 9. */
public final class TwoPhasePadesSigner {
/** What phase one must keep until phase two: store both byte arrays in a database or an object store. */
public record Prepared(byte[] preparedPdf, byte[] cmsContainer) { }
private final PadesTwoPhaseSigningHelper helper = new PadesTwoPhaseSigningHelper();
/** Phase one: no private key needed. Reserves the signature, computes the digest of the byte range. */
public Prepared prepare(byte[] unsignedPdf, Certificate[] chain, String fieldName) throws Exception {
SignerProperties properties = new SignerProperties()
.setFieldName(fieldName)
.setReason("Invoice issued by Bookshelf Billing")
.setLocation("Madrid");
ByteArrayOutputStream prepared = new ByteArrayOutputStream();
CMSContainer cms = helper.createCMSContainerWithoutSignature(
chain, "SHA256", new PdfReader(new ByteArrayInputStream(unsignedPdf)), prepared, properties);
return new Prepared(prepared.toByteArray(), cms.serialize());
}
/** The bytes that the remote signer must sign (the DER-encoded signed attributes of the CMS container). */
public byte[] dataToSign(Prepared prepared) throws Exception {
return new CMSContainer(prepared.cmsContainer()).getSerializedSignedAttributes();
}
/** Phase two: the remote signer is called and its value is embedded into the reserved space. */
public byte[] finish(Prepared prepared, IExternalSignature remoteSignature, String fieldName) throws Exception {
CMSContainer cms = new CMSContainer(prepared.cmsContainer());
ByteArrayOutputStream signed = new ByteArrayOutputStream();
helper.signCMSContainerWithBaselineBProfile(
remoteSignature, new PdfReader(new ByteArrayInputStream(prepared.preparedPdf())),
signed, fieldName, cms);
return signed.toByteArray();
}
}
Notes on this class:
-
Preparedis a record with the two byte arrays that must survive between the phases. In a service they go to a database row or an object store with a time-to-live, keyed by the invoice number or a random identifier. -
dataToSignreturns the DER-encoded signed attributes. A signer that works on digests needs only their SHA-256, whichRemoteDigestSignaturecomputes; a signer that wants the bytes (some CSC providers sign "data" and hash on their side) gets the array as it is. -
The helper closes the output stream passed to the second method. A
ByteArrayOutputStreamdoes not mind, a servlet response stream does, so give it a buffer and copy. -
PdfReaderinstances are single-use; create one per call.
The driver below creates the invoice, runs both phases against the stand-in, repeats phase two with a signature value that was computed in between (the end-user flow), and then runs the container-based variant of the next section.
package com.example.billing.signing;
import java.io.ByteArrayOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Paragraph;
public final class Demo {
public static void main(String[] args) throws Exception {
char[] password = System.getenv("BILLING_KEYSTORE_PASSWORD").toCharArray();
RemoteSigner remote = new LocalKeystoreRemoteSigner(Path.of("billing-signer.p12"), password, "billing-signer");
System.out.println("chain length: " + remote.certificateChain().length);
ByteArrayOutputStream plain = new ByteArrayOutputStream();
try (Document doc = new Document(new PdfDocument(new PdfWriter(plain)))) {
doc.add(new Paragraph("Invoice BB-2026-0001, Bookshelf Billing"));
}
Files.write(Path.of("invoice-plain.pdf"), plain.toByteArray());
TwoPhasePadesSigner signer = new TwoPhasePadesSigner();
var prepared = signer.prepare(plain.toByteArray(), remote.certificateChain(), "billing-signature");
System.out.println("prepared pdf bytes: " + prepared.preparedPdf().length + ", cms bytes: " + prepared.cmsContainer().length
+ ", data to sign bytes: " + signer.dataToSign(prepared).length);
byte[] signed = signer.finish(prepared, new RemoteDigestSignature(remote), "billing-signature");
Files.write(Path.of("invoice-two-phase.pdf"), signed);
System.out.println("signed bytes: " + signed.length);
// End-user variant: the signature value is produced elsewhere between the two phases.
byte[] attributes = signer.dataToSign(prepared);
byte[] value = remote.signDigest(java.security.MessageDigest.getInstance("SHA-256").digest(attributes));
Files.write(Path.of("invoice-precomputed.pdf"), signer.finish(prepared, new PrecomputedSignature(value), "billing-signature"));
DeferredContainerSigner container = new DeferredContainerSigner();
byte[] blank = container.prepare(plain.toByteArray(), "billing-signature");
byte[] signed2 = container.complete(blank, "billing-signature", remote);
Files.write(Path.of("invoice-container.pdf"), signed2);
System.out.println("container signed bytes: " + signed2.length);
}
}
Running it prints:
chain length: 2 prepared pdf bytes: 17784, cms bytes: 3692, data to sign bytes: 231 signed bytes: 17784 container signed bytes: 35536
The signed file has the same length as the prepared file because the CMS is written into space that was reserved
earlier. That also shows the main tuning knob. The reserved size defaults to an estimate derived from the certificate
chain and the digest, plus the size of the timestamp token when a TSAClient is configured, and can be set with
setEstimatedSize. If the CMS is larger than the reservation, phase two fails; if it is much smaller, the file carries
unused zero bytes. For a B-LT signature, where revocation data is added later in a separate revision, the reservation
needs room for the timestamp but not for the revocation data.
A small verifier checks the cryptographic integrity of each signature and whether it covers the whole document. It checks that the CMS matches the byte range; it does not check that the certificate is trusted, for which see the validation page of this section.
package com.example.billing.signing;
import java.io.ByteArrayInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.signatures.PdfPKCS7;
import com.itextpdf.signatures.SignatureUtil;
/** Integrity check only: proves the CMS matches the byte range, not that the certificate is trusted. */
public final class VerifyDemo {
public static void main(String[] args) throws Exception {
for (String file : args) {
try (PdfDocument pdf = new PdfDocument(new PdfReader(new ByteArrayInputStream(Files.readAllBytes(Path.of(file)))))) {
SignatureUtil util = new SignatureUtil(pdf);
for (String name : util.getSignatureNames()) {
PdfPKCS7 pkcs7 = util.readSignatureData(name);
System.out.println(file + " " + name + ": integrity=" + pkcs7.verifySignatureIntegrityAndAuthenticity()
+ ", coversWholeDocument=" + util.signatureCoversWholeDocument(name)
+ ", tsaTokenPresent=" + (pkcs7.getTimeStampTokenInfo() != null) + ", digest=" + pkcs7.getDigestAlgorithmName() + ", signer="
+ pkcs7.getSigningCertificate().getSubjectX500Principal().getName());
}
}
}
}
}
invoice-two-phase.pdf billing-signature: integrity=true, coversWholeDocument=true, tsaTokenPresent=false, digest=SHA256, signer=CN=Bookshelf Billing Signer,O=Bookshelf Test,C=ES invoice-precomputed.pdf billing-signature: integrity=true, coversWholeDocument=true, tsaTokenPresent=false, digest=SHA256, signer=CN=Bookshelf Billing Signer,O=Bookshelf Test,C=ES invoice-container.pdf billing-signature: integrity=true, coversWholeDocument=true, tsaTokenPresent=false, digest=SHA256, signer=CN=Bookshelf Billing Signer,O=Bookshelf Test,C=ES
The three files were produced by the helper with a remote call in phase two, by the helper with a precomputed signature
value, and by the container. The PrecomputedSignature class is the whole difference of the end-user flow:
package com.example.billing.signing;
import com.itextpdf.signatures.IExternalSignature;
import com.itextpdf.signatures.ISignatureMechanismParams;
/** For end-user signing: the signature value was produced elsewhere, phase two only embeds it. */
public record PrecomputedSignature(byte[] value) implements IExternalSignature {
@Override
public String getDigestAlgorithmName() {
return "SHA256";
}
@Override
public String getSignatureAlgorithmName() {
return "RSA";
}
@Override
public ISignatureMechanismParams getSignatureMechanismParameters() {
return null;
}
@Override
public byte[] sign(byte[] ignoredMessage) {
return value.clone();
}
}
It ignores the bytes iText passes in and returns the value that the end user’s device or the CSC provider produced
between the phases. Because the value was computed over the DER of the signed attributes taken from the serialised
container, phase two must use that same container. Rebuilding the container (a new phase one) prepares a new revision,
with a new /M date in the signature dictionary, so the byte-range digest in the signed attributes changes and the old
signature value no longer fits.
The same helper produces higher baseline levels. To get a B-T signature, configure a timestamp client on the helper before phase one
and call signCMSContainerWithBaselineTProfile in phase two. The two calls that change are:
// imports as in TwoPhasePadesSigner, plus com.itextpdf.signatures.TSAClientBouncyCastle
PadesTwoPhaseSigningHelper helper = new PadesTwoPhaseSigningHelper()
.setTSAClient(new TSAClientBouncyCastle("https://freetsa.org/tsr"));
CMSContainer cms = helper.createCMSContainerWithoutSignature(
remote.certificateChain(), "SHA256", new PdfReader(unsignedStream), preparedOut,
new SignerProperties().setFieldName("billing-signature"));
// ... store or pass on the prepared file and the container, then in phase two:
helper.signCMSContainerWithBaselineTProfile(new RemoteDigestSignature(remote),
new PdfReader(preparedStream), signedOut, "billing-signature", cms);
This variant was run against the public test service freetsa.org on 2026-10-09 and the resulting signature carried a
timestamp token and verified with the verifier above. The signCMSContainerWithBaselineLTProfile and
signCMSContainerWithBaselineLTAProfile methods additionally need an OCSP client, a CRL client and a set of trusted
certificates (setOcspClient, setCrlClient, setTrustedCertificates); with the self-signed test PKI there is no
revocation endpoint to query, so they were not exercised here. What the levels mean is in
PAdES Baseline Levels. The code derives from the iText API
documentation of
PadesTwoPhaseSigningHelper
and
PdfTwoPhaseSigner.
The low-level building block
PdfTwoPhaseSigner is what the helper calls. Its constructor takes a PdfReader and an output stream.
prepareDocumentForSignature(SignerProperties, digestAlgorithm, filter, subFilter, estimatedSize, includeDate) writes
the prepared file and returns the digest of the byte range, and the static addSignatureToPreparedDocument overloads
write either a CMSContainer or a finished CMS byte array into the placeholder. Use it directly when you build the CMS
with your own code, for example with Bouncy Castle’s CMSSignedDataGenerator, or when the CMS comes complete from a
provider and you have no use for a CMSContainer. In that case phase two is a single call: pass the reader over the
prepared file, the field name, the output stream and the CMS bytes.
IExternalSignatureContainer and signDeferred
The container API is the oldest of the three and the most general. An IExternalSignatureContainer has two methods.
sign(InputStream) receives the byte range as a stream and must return the complete CMS; modifySigningDictionary
lets the container set /Filter and /SubFilter (and anything else) on the signature dictionary. Two methods of
PdfSigner use it.
-
signExternalContainer(container, estimatedSize)is phase one. With the suppliedExternalBlankSignatureContainer, which returns an empty array, it just reserves the space. A custom container can instead compute and store the digest of the stream it is given, which is how you hand the digest to a remote party in phase one. -
The static
signDeferred(reader, fieldName, out, container)is phase two. It asks the container for the CMS of the byte range of the prepared file and writes it into the placeholder.
package com.example.billing.signing;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import com.itextpdf.kernel.pdf.PdfName;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.kernel.pdf.StampingProperties;
import com.itextpdf.signatures.ExternalBlankSignatureContainer;
import com.itextpdf.signatures.PdfSigner;
import com.itextpdf.signatures.SignerProperties;
/** The container-based variant: phase one reserves the signature, phase two fills it. */
public final class DeferredContainerSigner {
private static final int ESTIMATED_SIZE = 16_384;
/** Phase one: no key. The output has a signature field and an empty, zero-filled /Contents. */
public byte[] prepare(byte[] unsignedPdf, String fieldName) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
PdfSigner signer = new PdfSigner(new PdfReader(new ByteArrayInputStream(unsignedPdf)), out,
new StampingProperties().useAppendMode());
signer.setSignerProperties(new SignerProperties().setFieldName(fieldName).setReason("Deferred signing"));
signer.signExternalContainer(
new ExternalBlankSignatureContainer(PdfName.Adobe_PPKLite, PdfName.ETSI_CAdES_DETACHED),
ESTIMATED_SIZE);
return out.toByteArray();
}
/** Phase two: the container produces the CMS for the byte range and iText writes it into /Contents. */
public byte[] complete(byte[] preparedPdf, String fieldName, RemoteSigner remote) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
PdfSigner.signDeferred(new PdfReader(new ByteArrayInputStream(preparedPdf)), fieldName, out,
new RemoteCmsContainer(remote));
return out.toByteArray();
}
}
The container that builds the CMS from a remote signature value uses PdfPKCS7, the class that predates the
CMSContainer. In the code below, the CMS builder asks for the authenticated attributes, the remote signer signs their
digest, and the value is set back before the CMS is encoded.
package com.example.billing.signing;
import java.io.IOException;
import java.io.InputStream;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import com.itextpdf.kernel.pdf.PdfDictionary;
import com.itextpdf.kernel.pdf.PdfName;
import com.itextpdf.signatures.BouncyCastleDigest;
import com.itextpdf.signatures.IExternalSignatureContainer;
import com.itextpdf.signatures.PdfPKCS7;
import com.itextpdf.signatures.PdfSigner;
/**
* An IExternalSignatureContainer receives the byte range of the document and returns the complete CMS signature.
* Only the SHA-256 digest of the signed attributes leaves the process.
*/
public final class RemoteCmsContainer implements IExternalSignatureContainer {
private final RemoteSigner remote;
public RemoteCmsContainer(RemoteSigner remote) {
this.remote = remote;
}
@Override
public byte[] sign(InputStream byteRange) throws GeneralSecurityException {
try {
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] documentHash = sha256.digest(byteRange.readAllBytes());
PdfPKCS7 cms = new PdfPKCS7(null, remote.certificateChain(), "SHA256", null,
new BouncyCastleDigest(), false);
byte[] signedAttributes = cms.getAuthenticatedAttributeBytes(
documentHash, PdfSigner.CryptoStandard.CADES, null, null);
byte[] signatureValue = remote.signDigest(sha256.digest(signedAttributes));
cms.setExternalSignatureValue(signatureValue, null, "RSA");
return cms.getEncodedPKCS7(documentHash, PdfSigner.CryptoStandard.CADES, null, null, null);
} catch (IOException e) {
throw new GeneralSecurityException(e);
}
}
@Override
public void modifySigningDictionary(PdfDictionary signDic) {
signDic.put(PdfName.Filter, PdfName.Adobe_PPKLite);
signDic.put(PdfName.SubFilter, PdfName.ETSI_CAdES_DETACHED);
}
}
The sub-filter is ETSI.CAdES.detached and the attributes are requested in the CADES mode, so that the result is a
PAdES-compatible signature. Choosing adbe.pkcs7.detached and the CMS mode instead is the variant for a plain, non-PAdES PDF signature. Compared with
the helper, the container has one drawback: PdfPKCS7 keeps state such as the signing date inside the object, so the call that
produces the signed attributes and the call that encodes the CMS must happen on the same instance. That makes it a good
fit when both happen in one request (the server-side flow) and an awkward one when they are separated by a user’s
approval, where the serialisable CMSContainer is the better tool. The code derives from the
IExternalSignatureContainer
API documentation.
PKCS#11 with SunPKCS11: HSMs, smart cards and national eID
PKCS#11 is the standard C API for cryptographic tokens. A vendor delivers a shared library (a .so, .dll or
.dylib) that implements it for their device, and Java reaches it through the SunPKCS11 provider that ships with the
JDK. The same mechanism serves a network HSM (whose client library talks to the appliance), a USB token, a smart card
and a national eID card, whose issuing authority publishes a PKCS#11 module or a middleware that exposes one. The
private key is generated on the device and the library offers "sign this digest" but no "export this key".
The provider is configured with a small text file. The one below targets a SoftHSM-style test module; the library path, slot and name are the parts you change for your device.
name = BillingHsm
library = /usr/lib/softhsm/libsofthsm2.so
slotListIndex = 0
name becomes part of the provider name (SunPKCS11-BillingHsm), library is the vendor module and slotListIndex
selects the slot by position (the attribute slot takes the slot ID instead). The PIN is not in the file: it is
supplied when the keystore is loaded, from a secret store or an environment variable such as BILLING_HSM_PIN, never
from source code and never in logs. Most modules also accept the PIN from a protected authentication path (a PIN pad
on the reader), in which case load(null, null) is used.
package com.example.billing.signing;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.Security;
import java.security.cert.Certificate;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.signatures.IExternalSignature;
import com.itextpdf.signatures.PdfPadesSigner;
import com.itextpdf.signatures.PrivateKeySignature;
import com.itextpdf.signatures.SignerProperties;
/** SunPKCS11: the key stays on the token, the JCA hands the digest to the PKCS#11 module. */
public final class Pkcs11Signing {
public static byte[] sign(byte[] unsignedPdf, String configFile, char[] pin, String alias) throws Exception {
Provider provider = Security.getProvider("SunPKCS11").configure(configFile);
Security.addProvider(provider);
KeyStore tokenStore = KeyStore.getInstance("PKCS11", provider);
tokenStore.load(null, pin); // logs in to the token with the PIN
PrivateKey key = (PrivateKey) tokenStore.getKey(alias, null);
Certificate[] chain = tokenStore.getCertificateChain(alias);
// The third argument makes the JCA use the PKCS#11 provider instead of a software provider.
IExternalSignature signature = new PrivateKeySignature(key, "SHA256", provider.getName());
ByteArrayOutputStream out = new ByteArrayOutputStream();
new PdfPadesSigner(new PdfReader(new ByteArrayInputStream(unsignedPdf)), out)
.signWithBaselineBProfile(new SignerProperties().setFieldName("billing-signature"), chain, signature);
return out.toByteArray();
}
}
The important line is the one that builds the PrivateKeySignature with the provider name: it pins the SunPKCS11
implementation of SHA256withRSA, so the digest and the signature are computed by the token and the key object is
only a handle. Without the name the JCA relies on its provider selection, which can work when the provider is
registered but hides which implementation signs; being explicit makes a misconfiguration fail loudly.
This class was compiled but not run: it needs a PKCS#11 module and a token, and none was available when the page was written. The JDK reference describes the configuration attributes in the PKCS#11 Reference Guide. Practical points:
-
Concurrency. A token has a limited number of sessions and many devices serialise operations. A web service that signs many invoices needs a bounded executor or a pool in front of the provider, and a timeout, because a slow HSM must not exhaust the request threads.
-
Interactive devices. A smart card or eID card often requires a PIN per signature or per session, and some require the user to confirm on a pin pad. Such a device belongs to the end-user flow (the key holder is a person at a workstation), not to an unattended server.
-
Qualified signatures. A qualified electronic signature must be created in a qualified signature creation device. A national eID card with a qualified certificate is one; a plain HSM is not unless it is certified and operated as such. The legal classification is a question for the trust service provider, not for the code.
-
Certificates. The token usually stores the certificate chain next to the key. When it does not, load the chain from a file and match it to the key by the certificate’s public key.
The iText Knowledge Base explains the idea in more depth in Part III - PKCS#11 of its signing series, which is consulted for the concepts only.
Cloud KMS
AWS KMS, Azure Key Vault (and its Managed HSM tier) and Google Cloud KMS all offer asymmetric keys that are created in
the service, cannot be exported, and are used through an API call. They fit the RemoteSigner interface directly. The
cost is one HTTPS round trip per signature, which for a typical invoice is dominated by network latency, and the
operational surface of cloud access control. How the services themselves are set up (key policies, access policies,
roles, rotation, audit logs) is covered on the existing cloud pages of this site:
Secrets Manager, Parameter Store and KMS,
Key Vault and Secrets and
Secret Manager and KMS.
One problem is specific to signing and is easy to miss: a KMS stores a key pair, not a certificate. To sign a PDF with a certificate you need an X.509 certificate for the public key, issued by a CA that your recipients trust. For a test you can create a certificate request with the public key and let the test CA of this section issue it. In production you ask the certificate authority for a certificate for the key in the KMS, and the proof of possession that the CA demands is a certificate signing request that the KMS itself signs. The three classes below all take the chain as a constructor argument, and the chain is stored as configuration (a PEM file or a secret), not as part of the key.
AWS KMS
AWS KMS creates the key as an asymmetric SIGN_VERIFY key (for example RSA_3072 or ECC_NIST_P256). The Sign
operation accepts either the message or its digest. Set the message type to DIGEST, otherwise KMS hashes your digest a
second time and the signature never verifies. The AWS SDK for Java 2.x is licensed under Apache-2.0.
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>kms</artifactId>
<version>2.55.13</version>
</dependency>
package com.example.billing.signing;
import java.security.cert.X509Certificate;
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.services.kms.KmsClient;
import software.amazon.awssdk.services.kms.model.MessageType;
import software.amazon.awssdk.services.kms.model.SignRequest;
import software.amazon.awssdk.services.kms.model.SigningAlgorithmSpec;
/** AWS KMS asymmetric key (RSA_3072, SIGN_VERIFY). Region and credentials come from the default provider chain. */
public final class AwsKmsRemoteSigner implements RemoteSigner {
private final KmsClient kms;
private final String keyId;
private final X509Certificate[] chain;
public AwsKmsRemoteSigner(KmsClient kms, String keyId, X509Certificate[] chain) {
this.kms = kms;
this.keyId = keyId;
this.chain = chain.clone();
}
@Override
public X509Certificate[] certificateChain() {
return chain.clone();
}
@Override
public byte[] signDigest(byte[] sha256Digest) {
SignRequest request = SignRequest.builder()
.keyId(keyId) // key ID, key ARN or alias ARN
.message(SdkBytes.fromByteArray(sha256Digest))
.messageType(MessageType.DIGEST) // KMS must not hash it again
.signingAlgorithm(SigningAlgorithmSpec.RSASSA_PKCS1_V1_5_SHA_256)
.build();
return kms.sign(request).signature().asByteArray();
}
}
Region and credentials come from the default provider chain (environment, shared profile, container or instance role);
nothing is configured in code. The key identifier is read from your own configuration, for example from an environment
variable BILLING_KMS_KEY_ID. The IAM policy of the service role should allow only kms:Sign (and kms:GetPublicKey
if you read the public key at start-up) on that one key. The code derives from the
Sign API reference.
Azure Key Vault
Key Vault exposes RSA and EC keys; the CryptographyClient signs a digest with sign and hashes the data itself with
signData. Authentication goes through DefaultAzureCredential, which uses a managed identity in Azure, the Azure CLI
login on a workstation or environment variables. The Azure SDK for Java is licensed under MIT.
<dependency>
<groupId>com.azure</groupId>
<artifactId>azure-security-keyvault-keys</artifactId>
<version>4.11.3</version>
</dependency>
<dependency>
<groupId>com.azure</groupId>
<artifactId>azure-identity</artifactId>
<version>1.18.7</version>
</dependency>
package com.example.billing.signing;
import java.security.cert.X509Certificate;
import com.azure.identity.DefaultAzureCredentialBuilder;
import com.azure.security.keyvault.keys.cryptography.CryptographyClient;
import com.azure.security.keyvault.keys.cryptography.CryptographyClientBuilder;
import com.azure.security.keyvault.keys.cryptography.models.SignatureAlgorithm;
/** Azure Key Vault (or Managed HSM) RSA key. The key identifier is the full key URL, with or without version. */
public final class AzureKeyVaultRemoteSigner implements RemoteSigner {
private final CryptographyClient client;
private final X509Certificate[] chain;
public AzureKeyVaultRemoteSigner(String keyIdentifier, X509Certificate[] chain) {
this.client = new CryptographyClientBuilder()
.keyIdentifier(keyIdentifier)
.credential(new DefaultAzureCredentialBuilder().build()) // managed identity, CLI login, env vars
.buildClient();
this.chain = chain.clone();
}
@Override
public X509Certificate[] certificateChain() {
return chain.clone();
}
@Override
public byte[] signDigest(byte[] sha256Digest) {
// sign() takes a digest; signData() would hash the data inside the vault instead.
return client.sign(SignatureAlgorithm.RS256, sha256Digest).getSignature();
}
}
The key identifier is the key’s URL in the vault, from configuration. Grant the identity the sign permission only (the
role-based equivalent is the Key Vault Crypto User role scoped to the key). The REST operation is documented in
Keys - Sign.
Google Cloud KMS
Cloud KMS creates a key version for a purpose of ASYMMETRIC_SIGN with an algorithm such as
RSA_SIGN_PKCS1_3072_SHA256. The request carries the digest in a typed field, so the SHA-256 digest goes into
setSha256. The client uses application default credentials. The client library is licensed under Apache-2.0.
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-kms</artifactId>
<version>2.102.0</version>
</dependency>
package com.example.billing.signing;
import java.io.IOException;
import java.security.cert.X509Certificate;
import com.google.cloud.kms.v1.AsymmetricSignRequest;
import com.google.cloud.kms.v1.CryptoKeyVersionName;
import com.google.cloud.kms.v1.Digest;
import com.google.cloud.kms.v1.KeyManagementServiceClient;
import com.google.protobuf.ByteString;
/** Cloud KMS key version with purpose ASYMMETRIC_SIGN and algorithm RSA_SIGN_PKCS1_3072_SHA256. */
public final class GoogleKmsRemoteSigner implements RemoteSigner, AutoCloseable {
private final KeyManagementServiceClient client;
private final CryptoKeyVersionName keyVersion;
private final X509Certificate[] chain;
public GoogleKmsRemoteSigner(CryptoKeyVersionName keyVersion, X509Certificate[] chain) throws IOException {
this.client = KeyManagementServiceClient.create(); // application default credentials
this.keyVersion = keyVersion;
this.chain = chain.clone();
}
@Override
public X509Certificate[] certificateChain() {
return chain.clone();
}
@Override
public byte[] signDigest(byte[] sha256Digest) {
AsymmetricSignRequest request = AsymmetricSignRequest.newBuilder()
.setName(keyVersion.toString())
.setDigest(Digest.newBuilder().setSha256(ByteString.copyFrom(sha256Digest)))
.build();
return client.asymmetricSign(request).getSignature().toByteArray();
}
@Override
public void close() {
client.close();
}
}
The CryptoKeyVersionName is built from the project, location, key ring, key and version in your configuration. The
service account needs the cloudkms.cryptoKeyVersions.useToSign permission on that key version. The operation is
described in Create and validate digital signatures.
These three classes were compiled against the versions above and were not run, since that requires accounts and keys in each cloud. They show a pattern, not a complete integration: add retries with backoff for throttling, a timeout, and metrics on latency and failures, because the signing key is now a dependency whose outage stops invoice issuing.
Wiring is one @Bean method that chooses an implementation from configuration; the rest of the application, including
the two-phase signer, sees only RemoteSigner.
Cloud Signature Consortium API
The Cloud Signature Consortium (CSC) publishes an open API for remote signing, so that an application can use the credentials that a trust service provider manages for a person or an organisation, whatever the provider. The credential is a key plus its certificate, held in a qualified device at the provider. The consortium’s download page lists the current specification as API V2.2 as of 2026-10-09; the endpoints used here are those of the v2 family, checked against the v2.0.0.2 specification. The iText Knowledge Base describes how to use it with iText in Part V - CSC API.
The methods you need for a signature, in order:
| Method | Purpose |
|---|---|
|
Find the user’s credential ID, ask for the certificate chain (parameter |
|
Obtain the Signature Activation Data (SAD), a short-lived value that proves the signer gave consent. It carries
|
|
Send the Base64 hashes, the hash algorithm OID (SHA-256 is |
Requests carry an OAuth 2.0 bearer token. With service authorisation the application authenticates itself, which
is the mode for an organisational seal; with credential authorisation (explicit, or oauth2code) the person approves
each signature at the provider, which is the mode for personal signatures. A client for the explicit mode, written with
the JDK HttpClient and the Jackson 3 JsonMapper that Spring Boot 4 uses, looks like this:
package com.example.billing.signing;
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.cert.X509Certificate;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
/**
* Client for a Cloud Signature Consortium API v2 remote signing service (RSSP) with explicit credential
* authorization. The base URI, access token and credential ID come from configuration, never from code.
*/
public final class CscRemoteSigner implements RemoteSigner {
private static final String SHA256_OID = "2.16.840.1.101.3.4.2.1";
private static final String RSA_OID = "1.2.840.113549.1.1.1";
private final HttpClient http = HttpClient.newHttpClient();
private final JsonMapper json = JsonMapper.builder().build();
private final URI baseUri; // for example the value of BILLING_CSC_BASE_URI, ending in /csc/v2/
private final String accessToken; // OAuth 2.0 bearer token with the "service" scope
private final String credentialId;
private final X509Certificate[] chain; // from credentials/info with certificates=chain, parsed elsewhere
public CscRemoteSigner(URI baseUri, String accessToken, String credentialId, X509Certificate[] chain) {
this.baseUri = baseUri;
this.accessToken = accessToken;
this.credentialId = credentialId;
this.chain = chain.clone();
}
@Override
public X509Certificate[] certificateChain() {
return chain.clone();
}
@Override
public byte[] signDigest(byte[] sha256Digest) throws java.security.GeneralSecurityException {
try {
String hash = Base64.getEncoder().encodeToString(sha256Digest);
// 1. credentials/authorize binds a Signature Activation Data (SAD) to this hash.
JsonNode authorized = post("credentials/authorize", Map.of(
"credentialID", credentialId,
"numSignatures", 1,
"hashes", List.of(hash),
"hashAlgorithmOID", SHA256_OID));
String sad = authorized.get("SAD").asString();
// 2. signatures/signHash returns the raw signature value (Base64) for each hash.
JsonNode signed = post("signatures/signHash", Map.of(
"credentialID", credentialId,
"SAD", sad,
"hashes", List.of(hash),
"hashAlgorithmOID", SHA256_OID,
"signAlgo", RSA_OID));
return Base64.getDecoder().decode(signed.get("signatures").get(0).asString());
} catch (IOException | InterruptedException e) {
throw new java.security.GeneralSecurityException("CSC call failed", e);
}
}
private JsonNode post(String method, Map<String, Object> body) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(baseUri.resolve(method))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + accessToken)
.POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body)))
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IOException("CSC " + method + " returned HTTP " + response.statusCode());
}
return json.readTree(response.body());
}
}
The base URI, the bearer token and the credential ID come from configuration, and the chain is read from the
credentials/info response (a list of Base64 DER certificates that you parse with a CertificateFactory). Treat the
bearer token and the SAD as secrets: never log them, and do not put them in a URL.
This client was run against a small mock server written for the test (a JDK HttpServer that implemented the two
endpoints and answered signatures/signHash with the local stand-in), signing an invoice through the two-phase helper
of the previous section. The resulting file verified with integrity true. It was not run against a real provider, so
real-world details (error bodies, the exact SCAL behaviour, polling in the asynchronous mode A) are covered only by
the specification, which also defines credentials/extendTransaction for several signatures under one authorisation.
Download the specification from the
consortium’s page.
EU DSS: ExternalCMSService
EU DSS has a pair of classes for exactly this split. PAdESWithExternalCMSService is the client side: it prepares the
signature revision of the PDF and returns a DSSMessageDigest of the byte range (getMessageDigest), and later
embeds a CMS into the revision (signDocument). ExternalCMSService is the server side: it receives only the
message digest, builds the signed attributes and returns the data to be signed (getDataToSign); once a signature
value exists it returns the finished CMS (signMessageDigest). The PDF never reaches the server that holds the key, which
is the same property as in the iText flow. The classes belong to the module dss-pades, are licensed under LGPL-2.1,
and the DSS documentation describes them in the section on
PAdES with external CMS.
DSS 6.5 needs, besides dss-pades, a PDF implementation (dss-pades-pdfbox, built on Apache PDFBox, which is
Apache-2.0), a utilities implementation (dss-utils-apache-commons), a CMS implementation (dss-cms-object) and a CRL
parser (dss-crl-parser-x509crl). The absence of any of these fails when the first class that needs it is used, with an
exception (sometimes wrapped in an ExceptionInInitializerError) whose message names the alternatives, such as
No implementation found for ICRLUtils in classpath, please choose between dss-crl-parser-stream or dss-crl-parser-x509crl;
Signing with Apache PDFBox & EU DSS lists the three
messages.
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-pades</artifactId>
<version>6.5</version>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-pades-pdfbox</artifactId>
<version>6.5</version>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-utils-apache-commons</artifactId>
<version>6.5</version>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-cms-object</artifactId>
<version>6.5</version>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-crl-parser-x509crl</artifactId>
<version>6.5</version>
</dependency>
The example runs both halves in one process, with the local stand-in as the key holder:
package com.example.billing.signing;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.cert.X509Certificate;
import java.util.Date;
import eu.europa.esig.dss.enumerations.DigestAlgorithm;
import eu.europa.esig.dss.enumerations.SignatureAlgorithm;
import eu.europa.esig.dss.enumerations.SignatureLevel;
import eu.europa.esig.dss.model.DSSDocument;
import eu.europa.esig.dss.model.DSSMessageDigest;
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.model.x509.CertificateToken;
import eu.europa.esig.dss.pades.PAdESSignatureParameters;
import eu.europa.esig.dss.pades.signature.ExternalCMSService;
import eu.europa.esig.dss.pades.signature.PAdESWithExternalCMSService;
import eu.europa.esig.dss.spi.validation.CommonCertificateVerifier;
/** EU DSS 6.5: the PDF side prepares the revision, the CMS side signs a digest. Both halves run here. */
public final class DssExternalCmsDemo {
public static void main(String[] args) throws Exception {
RemoteSigner remote = new LocalKeystoreRemoteSigner(Path.of("billing-signer.p12"),
System.getenv("BILLING_KEYSTORE_PASSWORD").toCharArray(), "billing-signer");
DSSDocument unsigned = new FileDocument("invoice-plain.pdf");
// Client side (holds the PDF, never the key)
PAdESWithExternalCMSService pdfService = new PAdESWithExternalCMSService();
PAdESSignatureParameters pdfParameters = new PAdESSignatureParameters();
pdfParameters.setSignatureLevel(SignatureLevel.PAdES_BASELINE_B);
pdfParameters.setDigestAlgorithm(DigestAlgorithm.SHA256);
pdfParameters.setReason("Invoice issued by Bookshelf Billing");
DSSMessageDigest messageDigest = pdfService.getMessageDigest(unsigned, pdfParameters);
// Server side (holds or reaches the key, never sees the PDF)
ExternalCMSService cmsService = new ExternalCMSService(new CommonCertificateVerifier());
PAdESSignatureParameters cmsParameters = new PAdESSignatureParameters();
cmsParameters.setSignatureLevel(SignatureLevel.PAdES_BASELINE_B);
cmsParameters.setDigestAlgorithm(DigestAlgorithm.SHA256);
cmsParameters.bLevel().setSigningDate(new Date());
X509Certificate[] chain = remote.certificateChain();
cmsParameters.setSigningCertificate(new CertificateToken(chain[0]));
for (X509Certificate certificate : chain) {
cmsParameters.getCertificateChain().add(new CertificateToken(certificate));
}
ToBeSigned dataToSign = cmsService.getDataToSign(messageDigest, cmsParameters);
byte[] digest = java.security.MessageDigest.getInstance("SHA-256").digest(dataToSign.getBytes());
SignatureValue signatureValue = new SignatureValue(SignatureAlgorithm.RSA_SHA256, remote.signDigest(digest));
DSSDocument cms = cmsService.signMessageDigest(messageDigest, cmsParameters, signatureValue);
// Client side again
System.out.println("CMS valid: " + pdfService.isValidCMSSignedData(messageDigest, cms)
+ ", PAdES baseline compatible: " + pdfService.isValidPAdESBaselineCMSSignedData(messageDigest, cms));
DSSDocument signed = pdfService.signDocument(unsigned, pdfParameters, cms);
try (OutputStream output = Files.newOutputStream(Path.of("invoice-dss.pdf"))) {
signed.writeTo(output);
}
System.out.println("DSS signed: " + Files.size(Path.of("invoice-dss.pdf")));
}
}
CMS valid: true, PAdES baseline compatible: true DSS signed: 21346
The file verified with the iText verifier above, so the two libraries agree on the structure. One point surprised the
first attempt: DSS 6 defaults to SHA-512, so a remote signer that signs SHA-256 digests needs the digest algorithm set
explicitly on both parameter objects, otherwise signMessageDigest refuses the signature value because its digest
algorithm differs from the expected one. The parameters on the server side must describe the same signature level as the
client side. The same reservation of space and the same timestamp options as in iText apply; the PDFBox and DSS signing
APIs in general are covered in
Signing with Apache PDFBox & EU DSS.
Server-side or end-user signing
Who is the signer decides most of the architecture.
| Aspect | Server-side (seal, system signature) | End-user (personal signature) |
|---|---|---|
Signer |
The organisation: the invoice is sealed by Bookshelf Billing. |
A person: the customer signs the onboarding form. |
Key holder |
An HSM or KMS key owned by the organisation; credentials of the service identity. |
A credential owned by the person at a trust service provider, a smart card or eID in the user’s reader, or a key on the user’s device. |
Consent |
Implicit in the service’s authorisation; audit logs are the evidence. |
Explicit and per signature: PIN, OTP, biometric or app approval, bound to the hash (SAD). |
Flow |
Both phases in one request or two steps of a job, with the stand-in replaced by a KMS or HSM class. |
Phase one on request A; the digest goes to the person’s device or provider; phase two on request B, possibly minutes later. |
State between phases |
Usually none, or a few seconds in memory. |
Stored |
Legal weight |
An advanced electronic seal, qualified if the device and certificate are. |
An advanced or qualified electronic signature, depending on the credential. |
For the Bookshelf Billing scenario, invoices are issued by the organisation, so each invoice is sealed with a KMS or HSM key held by the service identity, in one request: prepare, sign the digest, complete. The customer onboarding form is different: the person signs it, usually with a CSC provider or a card in their own reader, so the service exposes two endpoints (or a job with a "waiting for signature" state).
| Step | What the service does |
|---|---|
|
Runs phase one, stores |
The person approves |
At the provider, in the browser with a local signing component, or on the card; the signature value comes back to the client or the service, depending on the provider. |
|
Loads |
The controller code is ordinary Spring Boot 4 (see Getting Started with Spring Boot) and is not shown, because the signing logic is the classes above.
Pitfalls of a stateful flow
-
Never trust a digest from the client. In the end-user flow the service computes the digest and sends it out; the signature value that comes back is verified by phase two before it is stored. If the service accepted a digest computed by the client, the client could get a signature over any content under a valid certificate.
-
Do not change the document between the phases. The prepared file is final; any modification changes the byte range. Two signers who both prepare the same base file produce two conflicting revisions; sign in sequence, and prepare the second signature on the output of the first.
-
Expire prepared state. A prepared record that waits forever is both a resource leak and a signing opportunity. The SAD of a CSC credential also has a short validity.
-
Make completion idempotent. A client that retries phase two must not create a second signature or fail confusingly; key the stored result on the prepared identifier.
-
Lock the form before sealing. If the document has fields that the person can still change, certify with the right access permissions (see the fundamentals page) or sign after the form is flattened.
Operational advice
-
Give the signer the least privilege. The service identity may call the signing operation on one key and nothing else: no key creation, no key deletion, no decrypt. The key’s own audit log then becomes the record of everything that was ever signed.
-
Set timeouts and a budget. Remote signing is on the request path of invoice issuing. Put a connect and a read timeout on every call, retry only idempotent operations, and decide what happens to invoices when the signer is unavailable (queue and retry later, which two-phase signing makes natural).
-
Do not log secrets or full digests. Log the invoice number, the key identifier and the outcome. PINs, bearer tokens, SADs and signature values do not belong in logs.
-
Plan for certificate renewal, not only key rotation. A cloud key can be rotated, but the certificate is bound to the public key; a new key needs a new certificate, a new chain in configuration, and a decision about timestamping old documents so they remain verifiable after the old certificate expires.
-
Test without the real device. Keep the
RemoteSignerseam, run the integration tests against the local stand-in and verify the output with the checks of the validation page. Run a smaller number of contract tests against the real service in a non-production account. -
Mind the licences. iText Core is AGPLv3 or commercial, EU DSS is LGPL-2.1, PDFBox is Apache-2.0, and the cloud SDKs are Apache-2.0 (AWS and Google) and MIT (Azure). A network service built on iText under the AGPLv3 has to offer its source to the users of the service.
Outdated points to avoid
-
PdfStamper.createSignature,PdfSignatureAppearance.setCryptoandsetExternalDigest: replaced byPdfSignerand the two-phase classes of iText 7 and later. -
adbe.pkcs7.sha1and SHA-1 digests handed to an external signer: useETSI.CAdES.detachedoradbe.pkcs7.detachedwith SHA-256 or stronger. -
Loading the private key from a PKCS#12 file in the web application: use a token, a KMS or a remote service so the key never enters the JVM.
-
Sending the whole document to a signing service: send the digest. Services that ask for the document usually return a signed copy and break your control of the layout and the baseline level.
-
Hashing the digest twice: pass a digest to a KMS only with the message type
DIGEST, and the equivalent flag of the other services. -
Treating a KMS key as a certificate: a PDF needs an X.509 certificate for the key, issued by a trusted CA.
References
-
iText Knowledge Base, Part III - PKCS#11 and Part V - CSC API (concepts only).
-
iText Knowledge Base, Using iText and AWS KMS to digitally sign a PDF document: Part 1.
-
iText Knowledge Base, How to use a Digital Signing Service (DSS) such as GlobalSign, with iText.
-
iText Java API, PadesTwoPhaseSigningHelper, PdfTwoPhaseSigner and IExternalSignatureContainer.
-
iText licensing, AGPLv3 licence.
-
Oracle, PKCS#11 Reference Guide and SunPKCS11 provider API.
-
Cloud Signature Consortium, API specifications.
-
AWS, KMS Sign API.
-
Microsoft, Key Vault Keys - Sign.
-
Google Cloud, Create and validate digital signatures.
-
EU DSS, PAdES with external CMS and source repository.
-
RFC 5652, Cryptographic Message Syntax.
-
In this section: Digital Signature Fundamentals, PAdES Baseline Levels, Signing PDFs with iText, Signing with Apache PDFBox & EU DSS and Signature Validation & Long-Term Validity.
-
Elsewhere on this site: AWS KMS, Azure Key Vault and Google Cloud KMS.