Skip to main content

Digital signatures

A digital signature proves two things about a PDF: who signed it, and that no byte it covers has changed since. PdfPinata signs with an X.509 certificate and writes PAdES signatures, the form the European eIDAS regulation expects.

Signing is split across two packages:

  • The core PdfPinata package holds the PDF side: the signature field, the space reserved for the signature, and the byte range it covers. It contains no cryptography. It is in namespace PdfPinata.Pdf.Signatures.
  • PdfPinata.Signing does the cryptography with System.Security.Cryptography.Pkcs, which is part of .NET. It targets net8.0 and net10.0 only, not netstandard2.1, so it is not available on Unity.
dotnet add package PdfPinata.Signing

Sign a document you have built

Signing never rewrites a file. It appends a new revision to the bytes that already exist. So save the document first, then sign the saved bytes:

using System.Security.Cryptography.X509Certificates;
using PdfPinata.Pdf.Signatures;
using PdfPinata.Signing;

using X509Certificate2 certificate = new X509Certificate2("signer.pfx", pfxPassword);
Pkcs7Signer signer = new Pkcs7Signer(certificate); // PAdES, SHA-256

using MemoryStream unsigned = new MemoryStream();
document.Save(unsigned, false);
unsigned.Position = 0;

using FileStream output = File.Create("signed.pdf");
PdfSigner.Sign(unsigned, output, signer, new PdfSignatureOptions
{
Reason = "Approved for payment",
Location = "London",
});

The certificate must have its private key. Pkcs7Signer signs through the platform's own key storage, so a key on a smart card or in a hardware security module works without PdfPinata ever reading the key. On .NET 9 and later, the compiler suggests X509CertificateLoader.LoadPkcs12FromFile in place of the X509Certificate2 constructor. Both give you a certificate you can pass to Pkcs7Signer.

Pkcs7Signer takes these options:

  • format: PdfSignatureFormat.Pades (the default) or PdfSignatureFormat.Pkcs7. Use Pkcs7 only for a reader too old to understand PAdES. PAdES binds the certificate into the signed data, which stops anyone from swapping in a different certificate.
  • hashAlgorithm: SHA-256 by default. SHA-1 and MD5 are refused.
  • chain: intermediate certificates to embed, so a verifier can build the chain without fetching them.
  • timestampProvider: see Add a trusted timestamp.

Sign an existing file

To sign a PDF that is already on disk, pass its stream to the same PdfSigner.Sign overload. To make changes first, open it with PdfDocumentOpenMode.Append and use the overload that takes a document:

PdfDocument document = PdfReader.Open("contract.pdf", PdfDocumentOpenMode.Append);
using FileStream output = File.Create("contract-signed.pdf");
PdfSigner.Sign(document, output, signer, new PdfSignatureOptions { FieldName = "Signature2" });

A document can be signed more than once. Each signature needs its own FieldName; the default is "Signature1". An earlier signature stays valid, but it no longer covers the whole file, because the later revision comes after it. A reader shows this as "signed, then changed", which is correct.

Visible and invisible signatures

A signature with no Rectangle is invisible. It is still a field on the page and still covers the whole document. Most machine-applied signatures are invisible.

To show the signature on a page, set PageIndex (counted from zero), Rectangle (in the same top-left coordinates XGraphics uses) and DrawAppearance. DrawAppearance receives an XGraphics the size of the rectangle, with its origin at the rectangle's top-left corner:

PdfSignatureOptions options = new PdfSignatureOptions
{
PageIndex = 0,
Rectangle = new XRect(50, 640, 230, 70),
DrawAppearance = (gfx, area) =>
{
gfx.DrawRectangle(XBrushes.WhiteSmoke, area);
gfx.DrawString("Signed by Accounts", font, XBrushes.Black, 8, 18);
},
};

What you draw is decoration. A reader validates the signature, not the picture.

Certification signatures

An ordinary signature is an approval: "I signed this". A certification signature also says what may happen to the document afterwards. Set PdfSignatureOptions.Certification:

PdfCertificationLevelLater revisions may
NotCertified (default)do anything; this is an approval signature
NoChangesAllowedchange nothing
FormFillingAllowedfill in form fields and add signatures
FormFillingAndAnnotationsAllowedfill in forms, sign, and add or change annotations

A document can carry only one certification signature, and it must be the first signature. PdfPinata refuses to certify a document that is already certified. It also enforces the level on a certified document you open: an operation the level forbids throws InvalidOperationException, and a full Save of a certified document is refused.

Add a trusted timestamp

The signing time in /M comes from the signer's own clock, so it proves nothing. A timestamp from a time-stamping authority (TSA) proves when the signature existed. That is PAdES B-T. Pass a timestamp provider to Pkcs7Signer:

using Rfc3161TimestampProvider tsa = new Rfc3161TimestampProvider(new Uri("https://tsa.example.com"));
Pkcs7Signer signer = new Pkcs7Signer(certificate, timestampProvider: tsa);

If the TSA cannot be reached, signing fails. It never falls back to an untimestamped signature without telling you.

Keep a signature verifiable for years

A certificate expires, and the services that could confirm it was valid stop answering. PAdES B-LT solves this by storing the certificates and revocation responses in the document itself, in a security store (/DSS). PdfSignatureValidationData.Add gathers that data for every signature in a document and appends it as a new revision. It works on a document someone else signed, and it does not invalidate any signature:

PdfDocument signed = PdfReader.Open("contract-signed.pdf", PdfDocumentOpenMode.Append);
using OcspRevocationDataProvider ocsp = new OcspRevocationDataProvider();
using FileStream output = File.Create("contract-ltv.pdf");
PdfSignatureValidationData.Add(signed, output, ocsp);

OcspRevocationDataProvider fetches OCSP responses from the responder each certificate names. It does not fetch CRLs. To supply revocation data another way, implement IRevocationDataProvider. PdfValidationData.IsPresent(document) tells you whether a document already has a security store.

Verify a signature

PdfSignatureVerifier.Verify checks every signature in a file and answers two separate questions:

  • IsIntact: the signature verifies over the bytes it covers.
  • CoversWholeDocument: those bytes are the whole file, apart from the signature itself.

IsValid is true only when both are true. A signature over the first revision of a longer file is intact but proves nothing about what was added later.

foreach (PdfSignatureVerification result in PdfSignatureVerifier.Verify(File.ReadAllBytes("signed.pdf")))
{
Console.WriteLine($"{result.Signature.FieldName}: valid={result.IsValid}, problem={result.Problem}");
if (result.HasTimestamp)
Console.WriteLine($" timestamped {result.Timestamp}");
}

To read what a signature claims without checking it, use PdfSignatures.InDocument(document), which returns the field name, reason, location, signing time, byte range and certification level.

Things to know

  • Do not call Save after signing. Save writes the whole file again from the object model. That renumbers objects and drops every earlier revision, so the signature covers bytes that no longer exist. To change a signed document, open it with PdfDocumentOpenMode.Append and use SaveIncremental. See Incremental saving.
  • The verifier checks integrity, not trust. It builds no certificate chain, consults no trust store and checks no revocation. A signature it calls valid may still use a certificate nobody should trust. A green tick in a PDF reader depends on the certificate chaining to a root that reader trusts.
  • Space for the signature is reserved in advance. Pkcs7Signer.EstimatedSignatureSize defaults to 16 KB. If a signature with a long chain or an embedded timestamp does not fit, Sign throws and names the property. Raise it and sign again.
  • PDF/A allows signatures. It forbids encryption, not signing. PdfPinata's own veraPDF checks do not include a signed document, so validate a signed PDF/A file yourself.
  • Filling in an existing empty signature field is not supported. PdfSigner always creates its own field.
  • PAdES B-LTA (archive timestamps refreshed over time) is not supported.
  • Write your own signer for a remote signing service by implementing IPdfSigner: a SubFilter, an EstimatedSignatureSize, and a Sign(Stream) method that returns the detached CMS signature. The core package needs nothing else, and this route also works on netstandard2.1.

See it in action

The Signing demo signs a document with a PAdES signature and a visible appearance, then reads the signature back and verifies it. The part printed below draws the pages. The signing itself is in the demo's Save override and helper methods, in SigningDemo.cs.

The full Signing demo
src/SampleApp/Demos/SigningDemo.cs
var heading = new XFont(BundledFontResolver.SansFamily, 16, XFontStyle.Bold);
var label = new XFont(BundledFontResolver.SansFamily, 9.5, XFontStyle.Bold);
var body = new XFont(BundledFontResolver.SansFamily, 9);
var mono = new XFont(BundledFontResolver.MonoFamily, 8);

var document = new PdfDocument();
document.Info.Title = "Signing";
document.Info.Author = "PdfPinata sample app";

// ----- page one: the document that gets signed ---------------------------------------------

var first = document.AddPage();
using (var gfx = XGraphics.FromPdfPage(first))
{
var prose = new XTextFormatter(gfx);

gfx.DrawString("Statement of agreement", heading, XBrushes.Black, 50, 70);

prose.DrawString(
"This page stands in for whatever it is that needed signing. Everything below the "
+ "rule is about the signature rather than the agreement, and the signature itself "
+ "is in the box at the foot of this page - drawn by this demo, because a signature "
+ "appearance is decoration the caller supplies and not something the library "
+ "invents.",
body, XBrushes.Black, new XRect(50, 92, 495, 60));

gfx.DrawLine(new XPen(XColors.Gainsboro, 0.8), 50, 165, 545, 165);

prose.DrawString(
"A PDF signature is a chicken-and-egg problem the format solves by cheating. The "
+ "signature covers the file and the signature is in the file, so the bytes to be "
+ "hashed cannot be known until the signature is written, and the signature cannot "
+ "be computed until they are.",
body, XBrushes.Black, new XRect(50, 185, 495, 48));

prose.DrawString(
"The way out is to write the file with a hole of a known size where the signature "
+ "will go, and a /ByteRange saying \"everything except that hole\" - then compute "
+ "the signature over what was written and patch it into the hole without moving a "
+ "single byte. Every field involved is written at a fixed width, and that is what "
+ "makes the second pass a patch rather than a re-layout.",
body, XBrushes.Black, new XRect(50, 243, 495, 62));

gfx.DrawString("What is signed, and what is not", label, XBrushes.Black, 50, 325);

prose.DrawString(
"The two spans either side of the hole, which between them are the whole file. A "
+ "signature is entitled to cover only part of one, and a signature covering only "
+ "part of a file is precisely how a document gets altered without the alteration "
+ "showing - so \"does it verify\" and \"does it cover everything\" are two "
+ "questions and both have to be answered yes.",
body, XBrushes.Black, new XRect(50, 340, 495, 62));

gfx.DrawString("The revision is appended, never rewritten", label, XBrushes.Black, 50, 420);

prose.DrawString(
"Not an optimisation. A signature covers a byte range of the file, so rewriting the "
+ "file invalidates it - and rewriting is exactly what Save does. Signing therefore "
+ "goes through SaveIncremental, and a document already signed keeps every earlier "
+ "signature intact. This is also why this demo overrides how it is written out: the "
+ "base class saves the document it was handed, and doing that here would throw the "
+ "signature away.",
body, XBrushes.Black, new XRect(50, 435, 495, 76));

// The rectangle the appearance is drawn into is declared in the options, in the same
// coordinates XGraphics uses. What goes inside it is drawn by the callback below and is
// decoration: drawing the word "signed" does not sign anything.
gfx.DrawRectangle(new XPen(XColors.Gainsboro, 0.8), SignatureBox);
gfx.DrawString("The signature appearance is drawn into this box",
new XFont(BundledFontResolver.SansFamily, 7.5), XBrushes.Gray,
SignatureBox.X, SignatureBox.Y - 6);
}

// ----- pages two and three: what the signing actually produced -----------------------------

// Signed here as a rehearsal, so that the pages below can report real numbers. A document
// cannot describe its own signature - the description would change the bytes the signature
// covers - so this signs a copy of page one alone and reports what came back. The file this
// demo writes is signed the same way, by the same signer, with the same options.
var rehearsed = Rehearse();

var second = document.AddPage();
using (var gfx = XGraphics.FromPdfPage(second))
{
var prose = new XTextFormatter(gfx);

gfx.DrawString("What the signature says", heading, XBrushes.Black, 50, 60);

prose.DrawString(
"Read back with PdfSignatures.InDocument, which walks the interactive form's field "
+ "tree and reports what it finds. Nothing here has been checked - the name, the "
+ "reason and the time are text the producer wrote, and are evidence of nothing on "
+ "their own.",
body, XBrushes.Black, new XRect(50, 80, 495, 44));

double y = 140;
foreach (var fact in rehearsed.Said)
{
gfx.DrawString(fact.Field, label, XBrushes.Black, 50, y);
gfx.DrawString(fact.Value, mono, XBrushes.Black, 205, y);
y += 16;
}

gfx.DrawString("PAdES or PKCS#7", label, XBrushes.Black, 50, y + 18);

prose.DrawString(
"The /SubFilter is what says how to read /Contents, and it belongs to the signer "
+ "rather than to the writer. PAdES - /ETSI.CAdES.detached - hashes the signing "
+ "certificate into the signed attributes, which closes a real hole: without it the "
+ "certificate is merely carried alongside the signature, and an attacker who can "
+ "find a second certificate whose key verifies the same signature can swap it in "
+ "and change who the document appears to have been signed by.",
body, XBrushes.Black, new XRect(50, y + 32, 495, 76));

gfx.DrawString("The time is not evidence", label, XBrushes.Black, 50, y + 120);

prose.DrawString(
"It is the producer's own clock, and a reader will say so. Making the time of "
+ "signing provable needs a timestamp token from a time-stamping authority - PAdES "
+ "B-T - and that is not implemented. PAdES also asks that the CMS signing-time "
+ "attribute be left out when /M carries the claim, because two claimed times that "
+ "can disagree help nobody, so Pkcs7Signer leaves it out for PAdES and puts it in "
+ "for PKCS#7.",
body, XBrushes.Black, new XRect(50, y + 134, 495, 76));

gfx.DrawString("Certifying, rather than approving", label, XBrushes.Black, 50, y + 222);

prose.DrawString(
"PdfSignatureOptions.Certification turns the signature into a /DocMDP one, which "
+ "declares what a later revision is still allowed to do - nothing, form filling, or "
+ "form filling and annotation. An ordinary signature says \"I signed this\"; a "
+ "certifying one says \"and here is what may still happen to it\".",
body, XBrushes.Black, new XRect(50, y + 236, 495, 58));
}

var third = document.AddPage();
using (var gfx = XGraphics.FromPdfPage(third))
{
var prose = new XTextFormatter(gfx);

gfx.DrawString("What verifying it proves", heading, XBrushes.Black, 50, 60);

prose.DrawString(
"PdfSignatureVerifier answers two questions and refuses to conflate them. IsIntact "
+ "says the signature verifies over the bytes it covers. CoversWholeDocument says "
+ "those bytes are the whole file but for the signature itself. A signature over the "
+ "first page of a five page document is perfectly intact, and reporting only that "
+ "would report the document as sound when a reader would not.",
body, XBrushes.Black, new XRect(50, 80, 495, 62));

double y = 160;
foreach (var fact in rehearsed.Verified)
{
gfx.DrawString(fact.Field, label, XBrushes.Black, 50, y);
gfx.DrawString(fact.Value, mono, XBrushes.Black, 205, y);
y += 16;
}

gfx.DrawString("Integrity checking, not validation", label, XBrushes.Firebrick, 50, y + 20);

prose.DrawString(
"No certificate chain is built, no trust store is consulted, no revocation is "
+ "checked and no timestamp is evaluated - all of which a reader showing a green "
+ "tick has done. A signature reported valid here may have been made with a "
+ "certificate nobody should trust, and the one on this file was: it is self-signed "
+ "by a key this demo generated a moment ago and threw away.",
body, XBrushes.Black, new XRect(50, y + 34, 495, 62));

gfx.DrawString("It is still the check that catches things", label, XBrushes.Black, 50, y + 108);

prose.DrawString(
"A document edited after signing, a signature covering only part of the file, a "
+ "producer that got its byte range wrong - those are what actually goes wrong, and "
+ "all three are questions about bytes rather than about who is believed.",
body, XBrushes.Black, new XRect(50, y + 122, 495, 48));

gfx.DrawString("Where the split is", label, XBrushes.Black, 50, y + 182);

prose.DrawString(
"The core package holds all the PDF machinery - the placeholder, the byte range, the "
+ "patching - and no cryptography at all, behind the IPdfSigner seam. "
+ "PdfPinata.Signing is the package that carries a dependency the core refuses, "
+ "and it is the one shipped package that does not target netstandard2.1. A signer "
+ "of your own - a smart card, an HSM, a remote signing service - implements "
+ "IPdfSigner and needs neither.",
body, XBrushes.Black, new XRect(50, y + 196, 495, 76));
}