Skip to main content

M26 — Signing

State: to do — Depends on: M04, M16 — Cryptography placed by ADR 41; the writer's reserved space by ADR 18; output versions by ADR 40

Goal​

Sign, certify and time-stamp documents the way French and European professionals must — PAdES B-B and B-T with a local key, a key held in an HSM, a deferred two-step flow or a qualified remote signing service — so that EU DSS validates each signature at the level it claims, every earlier signature still covers the revision it covered, and the PDF/A and PDF/UA claims the document made still hold; and open and write documents encrypted for certificates.

A contract is signed by both parties in turn, each in a field the template left for them; a law firm certifies a deed so that nothing but the counterparty's signature may follow it; a bailiff time-stamps a case file on filing, with no personal signature at all; an accounts department seals every invoice with the company's qualified seal, whose key never leaves a trust service provider. Everything around the signature — revisions, coverage, the permissions a certification sets and the changes it forbids — is M04's, without cryptography; the fields and placeholders are M16's and M17's; this milestone adds the cryptography, in the satellite ADR 41 gives it. The failures it exists to prevent are the ordinary ones: a second signature that invalidates the first because the save rewrote the file; a certification written after an approval signature, which validators then reject; a signature whose /Contents was cut because the time-stamp token was larger than the space reserved; a visible signature that voids the PDF/A claim of the invoice it seals, through a font it did not embed; a signing time read from the server's clock, so that the same inputs never give the same bytes; a remote service's signature over a hash other than the one the document needed, written without a check; a document signed over a revision history the reader had to guess.

Scope​

In:

  • the AdCodicem.Pdf.Signing satellite, depending on the core and System.Security.Cryptography.Pkcs, AOT- compatible and trimmable, with its own API baseline;
  • the core's side of signing made public: M03's reserved region as a signing reservation — placeholders laid out, byte ranges known, the digest fed while the original bytes stream through, the gap patched before the flush — and, for the two-step flow, the patch of a prepared file's /Contents located by M04;
  • an algorithm-neutral IPdfSigner: the certificate chain, the signature algorithm, and the signature over the bytes to be signed; a local implementation over a certificate and its RSA or ECDSA key; the path to an HSM through any RSA or ECDsa whose private operations run elsewhere, with no dependency of ours;
  • a CMS SignedData writer of our own, over System.Formats.Asn1 — SignedCms cannot sign with a key it does not hold, nor in two steps —, each signature verified by the framework's SignedCms before it is written;
  • PAdES B-B and B-T (ETSI.CAdES.detached), and adbe.pkcs7.detached on request for legacy validators; the signed attributes each level requires, a signature policy, commitment types and claimed roles on request;
  • RFC 3161 time stamps through an injectable IPdfTimestampClient, an HTTP implementation over the caller's HttpClient, every token checked before it is used; signature time stamps (B-T) and document time stamps on any document, signed or not;
  • signature fields: an empty field signed — M16's and M17's placeholders, and third-party fields —, or one created invisible or visible; seed values (/SV) honored; field locks (/Lock) written as their FieldMDP transform; sequential signers, each in a revision of its own, through M04's write guard;
  • visible appearances — text, an image of a handwritten signature, or a form XObject the caller built (M12.6 from HTML) — that keep a PDF/A claim and a PDF/UA claim;
  • certification (DocMDP with P 1, 2 or 3, and /Perms), refused where ISO 32000-2 forbids it;
  • deferred two-step signing across processes, and a CSC API v2 client for remote qualified signatures and seals;
  • RSA PKCS #1 v1.5, RSASSA-PSS and ECDSA on the NIST curves and, where the platform has them, the Brainpool curves; SHA-256, 384 and 512, and SHA-3 where the platform has it; EdDSA from an external signer, whose algorithm the writer carries without knowing it;
  • the public-key security handler (ADR 41): documents encrypted for certificates opened through M16's IPdfDecryptionKeySource with EnvelopedCms, and written for a list of recipients;
  • the CMS clauses of PDF/A contributed to M20's pdfa-signature family through its public rule API, as M20 left them to this satellite;
  • network off by default: no time-stamp authority and no remote service is called unless the caller supplies its client; the signing time is the caller's, never read from a clock the caller did not pass;
  • the command-line tool's sign verb, with document time stamps, and encrypt --recipient, decrypt --key.

Out, explicitly:

  • B-LT and B-LTA — the document security store, revocation data, archive time stamps, their renewal and long-term material added to others' signatures — M27; a seed value that requires revocation data at signing (/AddRevInfo) is refused here until M27 honors it;
  • validating any signature, ours or received, beyond the self-check that the CMS we write verifies over the digest we computed — chains, revocation, trust and verdicts are M27's;
  • verifying EdDSA and SHA-3 signatures — M27; signing with SHA-3 is offered where the platform supports it, EdDSA only through an external signer;
  • post-quantum signatures (ML-DSA, SLH-DSA) — not planned until the PDF specification text for them exists; IPdfSigner carries any algorithm identifier, so nothing in its shape prevents them;
  • the legacy sub-filters adbe.pkcs7.sha1 and adbe.x509.rsa_sha1, deprecated by ISO 32000-2, and Acrobat's layered appearances (n0 to n4) — never produced; read by M04 and M27, drawn by M25;
  • signing with SHA-1 or MD5, or with an RSA key under 2048 bits — refused, except behind an option the tests use;
  • the legal attestation dictionary, audit-trail pages, and whether a signature is qualified: the library signs with the key and certificate the caller gives and claims nothing about them; M27 reports what the trusted lists say;
  • XML signatures inside XFA, and signing dynamic XFA — never (ADR 37);
  • hosting a time-stamp authority, a signing service or a key: never; an EUDI Wallet flow beyond the CSC API is an open question.

Design​

Where it lives​

NamespaceHolds
AdCodicem.Pdf.SigningPdfSigning (the entry point), PdfSigningOptions, IPdfSigner, PdfLocalSigner, PdfSignatureAppearance, the CMS writer (internal), the PDF/A rules contributed to M20
AdCodicem.Pdf.Signing.TimestampsIPdfTimestampClient, HttpTimestampClient, PdfTimestampToken
AdCodicem.Pdf.Signing.DeferredPdfDeferredSigning, PdfSigningRequest and its JSON
AdCodicem.Pdf.Signing.CscCscClient, CscSigner, the CSC API v2 model
AdCodicem.Pdf.Signing.EncryptionCertificateDecryptionKeySource, PdfCertificateEncryption

System.Security.Cryptography.Pkcs parses every CMS structure the satellite receives — a token from a time-stamp authority, a CMS returned by a service, a recipient's EnvelopedData — and verifies what the satellite writes. The satellite writes SignedData itself with AsnWriter: writing is not the parsing of hostile input ADR 41 keeps out of our code, and SignedCms offers no way to sign with an external signature or in two steps. HTTP is the BCL's, through the caller's HttpClient; JSON through System.Text.Json source generation.

The core's side​

  • PdfSignatureReservation — M03's reserved region, public for this purpose: the change set carries a signature dictionary whose /Contents is a placeholder of a reserved size and whose /ByteRange is four fixed-width integers padded with spaces; once the appended revision is laid out in M03's pooled buffer, the reservation knows the byte ranges, has fed a digest sink with the original bytes as they streamed to the output and with the buffer outside the gap, and accepts Complete(contents) exactly once, before the flush. The original bytes are read once, never held.
  • PdfPreparedSignature.Patch(stream, contents) — for the two-step flow: the prepared file's /Contents is located by M04's lexing of the signature dictionary, its byte range checked against the file, and the gap overwritten in place with exactly its own length. It is the one write that is not forward-only, and it stays in the core, where offsets are known.
  • M04's write guard classifies every signing save as the next revision and refuses what a certification or a lock forbids; M16's field model and AddSignatureField provide fields and widgets; M16's IPdfDecryptionKeySource and encryption writer carry the public-key handler.

The signing pipeline​

PdfSigning.SignAsync(document, output, PdfSigningOptions, cancellationToken, progress):

  1. The field. The one the options name — an empty signature field, found by its fully qualified name — or a new one, invisible (a zero rectangle) or visible (a page and a rectangle). A field that is already signed, not a signature field, or locked by an earlier signature is refused. Its seed values are checked (below).
  2. The guard. M04's classification of the change set as the next revision: a new signature is NewSignature, a document time stamp DocumentTimestamp; refused when a certification forbids it, when the history is unreliable (a rebuilt index) unless the caller insists, and on an encrypted document without its password.
  3. The change set: the signature dictionary (/Type /Sig, /Filter /Adobe.PPKLite, /SubFilter, /M from the caller's time, /Reason, /Location, /ContactInfo when given, /Prop_Build naming the library and its version unless omitted), the field's /V, the widget and its appearance, /Reference with a DocMDP or FieldMDP transform where one applies, /Perms /DocMDP for a certification, /AcroForm /SigFlags 3, and the /Extensions the revision needs, unioned by ADR 40's policy. /Info and the metadata stream are not touched: a signature changes nothing it does not need to, which keeps a PDF/A document's XMP and /Info in agreement.
  4. The reservation. /Contents is reserved from the chain's DER length, the key's largest signature, the signed attributes, and PdfSigningOptions.TimestampReserve when a token will be added — its default measured on the test authority and on the corpus's tokens and recorded in docs/status.md.
  5. The digest over the byte range, streamed: the original file through the reservation's sink, then the revision outside the gap.
  6. The signed attributes, DER-encoded as a SET OF, sorted (below).
  7. The signature, from IPdfSigner.SignAsync(dataToBeSigned, algorithm, cancellationToken).
  8. The CMS: SignedData version 1, the digest algorithm, encapContentInfo of type id-data with no content (detached), the certificates the signer gives, one SignerInfo identified by issuer and serial number, and for B-T the unsigned signature-time-stamp attribute, a token over the signature value's digest.
  9. The self-check: SignedCms.Decode of what was written, CheckSignature(verifySignatureOnly: true), and the message-digest attribute equal to the digest of step 5. A CMS that does not verify is never written (PdfSigningException, SignatureDoesNotVerify) — the defense against a remote service or an HSM that signs something else.
  10. The fit: a CMS larger than the reservation fails (ContentsOverflow) with the size it needed, before anything is flushed; the caller retries with a larger reserve. Nothing is ever truncated, and the signer is never asked twice behind the caller's back — a remote service may count, or bill, each signature.
  11. The patch and the flush: /Contents written as hexadecimal into the gap, zero-padded, and the revision flushed. The output is the input, byte for byte, followed by one revision.

Algorithms​

SignatureDigestsWhere the key may be
RSA PKCS #1 v1.5SHA-256, 384, 512; SHA3-256, 384, 512 where SHA3_256.IsSupportedLocal, HSM, deferred, CSC
RSASSA-PSS (MGF1 with the same digest, salt the digest's length)The sameLocal, HSM, deferred, CSC
ECDSA on P-256, P-384, P-521; brainpoolP256r1, P384r1, P512r1 where the platform names the curveSHA-256, 384, 512Local, HSM, deferred, CSC
EdDSA (Ed25519 with SHA-512, Ed448 with SHAKE256), ISO/TS 32002Fixed by the algorithmDeferred and CSC only: the BCL has no EdDSA key

An algorithm the platform cannot run is refused before any byte is written (AlgorithmUnavailable), never degraded to another. An ISO/TS 32001 digest or an ISO/TS 32002 algorithm adds its /Extensions entry, unioned by ADR 40's policy. A signature's AlgorithmIdentifier is the signer's: the writer copies it, so a new algorithm needs a signer, not a new writer.

PAdES levels and legacy signatures​

FormatSigned attributesUnsignedWhere the time is
PAdES B-B (ETSI.CAdES.detached)content-type, message-digest, ESS signing-certificate-v2; on request signature-policy-identifier, commitment-type-indication, signer-attributes-v2 (claimed roles)—/M, the caller's; no signing-time attribute
PAdES B-Tas B-Bsignature-time-stampthe token's genTime, /M beside it
B-T by document time stampas B-B—a document time stamp added in the next revision
adbe.pkcs7.detachedcontent-type, message-digest, signing-time from /M, ESS signing-certificate-v2signature-time-stamp on requestboth

The rules the table encodes — no signing-time under PAdES, no /Reason beside a commitment-type-indication, the certificate attribute's form and hash — are checked against ETSI EN 319 142-1's text before slice 2 writes them, and each is a refusal or a report, never a silent drop. Signed attributes are sorted as DER requires; node- signpdf's unsorted set in the corpus shows what validators do when they are not. In 1.7 output, the ETSI sub-filters, the document time stamp and anything later written by M27 declare the /ESIC extension at the level PAdES names for them; in 2.0 output they are native.

Time stamps​

IPdfTimestampClient RequestAsync(hash, hashAlgorithm, cancellationToken) -> PdfTimestampToken
HttpTimestampClient over the caller's HttpClient and URI: policy OID, certReq, a nonce source, an
authorization header, MaxResponseLength
PdfTimestampToken the token's bytes, its genTime, policy, serial number, the TSA's certificate
  • The request is built with Rfc3161TimestampRequest.CreateFromHash, posted as application/timestamp-query, the response read up to MaxResponseLength and processed by the framework, which checks the imprint and the nonce. The satellite then checks the status is granted, the policy is the one required when one is, the token carries its signer's certificate with the time-stamping extended key usage, and the token's own signature verifies. Anything else is TimestampRejected, with what failed; nothing is retried behind the caller's back.
  • The nonce is 8 random bytes by default and the caller's when given: a nonce protects against replay, and the token's bytes are the authority's, not ours, so determinism is not at stake.
  • Document time stamps (PdfSigning.TimestampAsync): a signature field, invisible, whose value has /Type /DocTimeStamp and /SubFilter /ETSI.RFC3161, and whose /Contents is the token over the byte range's digest. On an unsigned document it is the horodatage of a filing; after signatures, a B-T by time stamp; under any certification, since M04 classes it DocumentTimestamp, which every P allows.

Fields, seed values, locks and sequential signers​

  • Seed values (/SV, ISO 32000-2 §12.7.5.5): /Filter, /SubFilter, /DigestMethod, /Reasons, /MDP, /TimeStamp (a URL and whether it is required), /Cert (subject, issuer, OIDs, key usage — the certificate checked against them), /LegalAttestation, /AddRevInfo. Each constraint whose /Ff bit marks it required refuses a signature that does not meet it (SeedValueViolated, naming the entry); an optional one that is not met is reported (signing.seed-value-ignored). A required time-stamp server with no client supplied is refused: the URL in the file is text, never called on its own.
  • Locks: the field's /Lock — All, Include or Exclude with field names, and in 2.0 its /P — becomes the signature's /Reference FieldMDP transform, so that the lock M04 reads from both places agrees.
  • Sequential signers: each signature is an incremental update after the last; M04's guard allows a NewSignature under P 2 and 3 and under no certification, and refuses a change to a field an earlier signature locked. The contract workflow — M16 fills, then two parties sign in the two fields M17 left — is three revisions, each covered by the signatures after it.
  • Usage rights: signing a Reader-extended form keeps its usage-rights signature covering its revision, and the grants are M16's to judge: a new signature outside what /Signature grants is reported as M16 reports lost rights.

Visible appearances​

  • Content: PdfSignatureAppearance.Text(lines) — the signer's name, the date as the caller formats it, the reason and location — laid out by M08's simple path in the widget's box; .Image(png or jpeg) beside or behind the text; .FormXObject(form) for an appearance the caller built — M12.6 renders one from an HTML fragment. The appearance is one form XObject in /AP /N: no layers.
  • PDF/A: fonts embedded as subsets (M08), never a standard 14 font; the Print flag set and Hidden, Invisible, NoView clear; no transparency under part 1 (an image with an alpha channel refused or flattened onto white, by M09's PdfConformancePolicy); colors in the output intent's space or a declared calibrated one; an invisible signature's zero rectangle, which PDF/A exempts from an appearance. The CMS clauses — the signer's certificate included, DER encoding, the sub-filter a part allows — are the rules this satellite contributes to M20's pdfa-signature family; each is checked against ISO 19005-2, 3 and 4's text before it is written.
  • PDF/UA: the widget in a Form structure element with its OBJR, /TU naming the field for assistive technology, /Tabs /S on the page — M16's tagging, applied to the new widget; an existing tagged field keeps its element.

Certification​

PdfSigningOptions.Certify(P) writes a /Reference with /TransformMethod /DocMDP, /TransformParams with /P and /V /1.2, and /Perms /DocMDP naming the signature dictionary. ISO 32000-2 wants a certification to be the first signature: a document with an approval signature, or with a certification already, refuses another (CertificationRefused), as M04's signature.permissions-malformed would report it otherwise. Document time stamps and security stores after it are allowed at any P, as M04 classifies them.

Keys held elsewhere​

  • HSM: PdfLocalSigner takes any RSA or ECDsa whose private operations run where the caller's provider runs them — a PKCS #11 wrapper, a CNG or KSP key, a cloud key vault's client — and a chain. The library holds no key material and no provider dependency; the documentation shows a PKCS #11 adapter.
  • Deferred, two steps, two processes: PdfDeferredSigning.PrepareAsync(document, options, output) writes a complete file whose signature /Contents is zeros — M04 reads it as Placeholder, viewers as an unsigned field — and returns a PdfSigningRequest: the field, the algorithm, the digest to sign, the signed attributes' DER, the byte range, the prepared file's SHA-256 and the gap's location, serializable to JSON with a published schema. CompleteAsync(prepared, request, signatureValue or cms) re-reads the prepared file through M04, checks that its byte range and digest are the request's, builds the CMS around the signature value — or checks that a CMS returned whole signs that digest —, adds a time-stamp token when asked, runs the self-check, and patches the gap. A prepared file that changed in between is refused (PreparedFileChanged).
  • CSC API v2 (Cloud Signature Consortium, versions 2.0 and 2.2): CscClient over the caller's HttpClient, base URI and an OAuth 2.0 access-token provider the caller implements; info, credentials/list, credentials/info (the chain, the key's algorithms, the authorization mode and the SCAL), credentials/authorize for the one hash to sign — the SAD obtained through a callback the caller implements when the mode asks for a PIN or a one-time password —, signatures/signHash. CscSigner : IPdfSigner drives it; SCAL 2 binds the hash in the authorization, which the prepared digest allows. Errors map to typed failures with the service's own code.

Encryption for certificates​

  • Opening: CertificateDecryptionKeySource : IPdfDecryptionKeySource takes the caller's certificates with their keys; for each entry of /Recipients it decodes the EnvelopedData with EnvelopedCms, finds a recipient matching a certificate, and returns the 20-byte seed and the 4 permission bytes; M16 derives the file key. A recipient kind the platform cannot decrypt — key agreement where the framework lacks it — is reported (encryption.recipient-unsupported), never guessed.
  • Writing: PdfCertificateEncryption with recipient groups, each a set of certificates and its permissions; /Filter /Adobe.PubSec, /SubFilter /adbe.pkcs7.s5, AES-128 or AES-256 crypt filters, metadata clear on request; one EnvelopedData per group, AES-256-CBC content encryption, RSA key transport (PKCS #1 v1.5 by default, which Acrobat reads; OAEP on request). The seed is random, as M16's keys and initialization vectors are: the documented exception to invariant 6, and the caller may supply the random source.

Bounds, classified (invariant 12, ADR 34)​

Documents are read through the core, whose guards apply. What the satellite reads besides comes from services and callers:

BoundKindWhy
A time-stamp response's lengthOption, HttpTimestampClient.MaxResponseLength (proposed 1 MB), TimestampRejectedA token has no fixed size; a hostile or broken server can stream without end
A CSC response's lengthOption, CscClient.MaxResponseLengthSame reason
The /Contents reserveOption, PdfSigningOptions.ContentsReserve or computed; ContentsOverflow names the size neededA valid chain and token may be large; the reserve is written before the CMS exists
A deferred request's JSONOption on the reader, with every field validated against the prepared fileIt crosses a process boundary and may be tampered with
Seed values, fields and locks read from the fileThe reader's guards; walks iterative with visited sets, as M04'sHostile documents

Diagnostics and failures​

Code or failureSeverityMeaning
SeedValueViolatedRefusalA required seed value not met, named
signing.seed-value-ignoredWarningAn optional seed value not met
CertificationRefusedRefusalA certification after an approval signature or a second certification
WeakAlgorithm, AlgorithmUnavailableRefusalSHA-1, MD5 or a short RSA key; an algorithm the platform lacks
TimestampRejectedRefusalStatus not granted, imprint, nonce or policy wrong, no TSA certificate, a token that does not verify
SignatureDoesNotVerifyRefusalThe self-check failed: nothing written
ContentsOverflowRefusalThe CMS exceeds the reserve; the size needed is given
HistoryUnreliableRefusal unless insistedThe reader rebuilt the index; an insisted signature is reported signing.history-unreliable
PreparedFileChangedRefusalThe prepared file's bytes or byte range differ from the request's
signing.usage-rights-lostWarningA signature outside what a form's usage rights grant
encryption.recipient-unsupportedWarningA recipient the platform cannot decrypt

Refusals raise PdfSigningException with a PdfSigningFailure value; M04's guard raises its own PdfSignatureInvalidationException. Every other outcome is in the signing report, which lists the signature, its revision, its byte range and the diagnostics.

The command-line tool​

sign FILE --out OUT [--field NAME | --new-field PAGE:x,y,w,h | --invisible] --cert CERT.pfx --password-env VAR [--level b-b|b-t] [--tsa URL] [--certify 1|2|3] [--time 2026-09-27T10:00:00Z | --time now] [--reason …] [--appearance text|image:PATH], sign FILE --document-timestamp --tsa URL, encrypt FILE --recipient CERT.pem [--permissions …], decrypt FILE --key KEY.pfx. The tool refuses to guess a time: --time now is spelled out.

Slices​

Each slice ends on a green commit, with its failures and codes documented and its benchmark, if it has one, recorded in docs/status.md. Every referee runs in a container (ADR 27); the test PKI is described below.

  1. The satellite and the reservation. Delivers the package, its baseline and AOT check; PdfSignatureReservation over M03's region; an unsigned placeholder written in a new field. Proved by unit tests (fixed-width byte ranges up to files of 2⁴⁰ bytes; a reservation completed twice refused; the digest sink fed exactly the covered bytes); FsCheck — for any input document and reserve, the byte ranges are ascending, start at 0, end at the file's end and leave exactly the /Contents string; integration: M04 reads our placeholder as Placeholder, qpdf --check passes, the input is a byte-identical prefix. Leaves the CMS.
  2. The CMS writer and the local signer: B-B. Delivers IPdfSigner, PdfLocalSigner, the SignedData writer, the signed attributes, the self-check, ETSI.CAdES.detached and adbe.pkcs7.detached, RSA, PSS and ECDSA. Proved by unit tests (DER of every attribute against fixed vectors; the sorted SET OF; a signer that returns a wrong signature refused); integration: openssl cms -verify over the extracted /Contents and byte range; pyHanko finds each signature intact and valid under the test root; EU DSS reports PAdES-BASELINE-B and TOTAL_PASSED at the test time with the test root trusted. Leaves time stamps.
  3. Time stamps: B-T and document time stamps. Delivers IPdfTimestampClient, HttpTimestampClient, token checks, the unsigned attribute, TimestampAsync, the /ESIC extension in 1.7 output. Proved by unit tests (each rejection: a status of rejection, granted with modifications, a wrong imprint, a wrong nonce, no certificate, an oversized response); integration: against the test authority, DSS reports PAdES-BASELINE-T and the timestamp valid; a document time stamp on every unsigned committed invoice, report and contract passes pyHanko and DSS. Leaves fields.
  4. Fields, seed values, locks and sequential signers. Delivers signing existing and new fields, /SV, /Lock to FieldMDP, the guard on every save. Proved by unit tests (each seed value, required and optional; a locked field; a field already signed); integration: the empty fields and the signed documents of the acceptance rows — pyHanko lists each field signed and every earlier signature intact, M04 classes each new revision NewSignature, pdfsig reads the same ranges for the earlier signatures. Leaves certification.
  5. Certification. Delivers Certify(P), /Perms, the refusals. Proved by unit tests (after an approval, a second certification, each P); integration: our certification of the contract at each P, then M16's fill, an M11 annotation and a second signature: pyHanko's modification level is the one P allows, each forbidden change refused by M04's guard and, insisted on, reported by pyHanko as a suspicious modification; DSS's report on the certification. Leaves appearances.
  6. Visible appearances, PDF/A and PDF/UA. Delivers the three appearance kinds, the conformance handling, the rules contributed to M20. Proved by unit tests (fonts embedded, flags, the refusal of an alpha image under PDF/A-1); integration: veraPDF upholds each claim of the PDF/A and PDF/UA rows, agrees with the contributed rules on every signed PDF/A document of the corpus, and MuPDF renders each appearance within M25's cross-engine threshold of our own rendering. Leaves keys held elsewhere.
  7. HSM and deferred signing. Delivers the RSA and ECDsa path, PdfDeferredSigning, the request's JSON schema, PdfPreparedSignature.Patch. Proved by unit tests (a key whose private operation runs in another process; a prepared file changed by one byte, by a new revision, or with a different gap, refused); integration: a two-step signature completed by a separate process, DSS TOTAL_PASSED; the prepared file read by M04 and pyHanko as unsigned. Leaves the CSC API.
  8. The CSC API client. Delivers CscClient and CscSigner, versions 2.0 and 2.2, both SCALs, the SAD callback, typed errors. Proved by unit tests over recorded exchanges (an expired SAD, invalid_grant, a hash count mismatch, an oversized response); integration: a CSC test service built on Certomancer, as pyHanko's own CSC tests use, in a container — signatures through it validate in DSS; one whose returned signature was altered by the test is refused by the self-check. Leaves encryption.
  9. Encryption for certificates. Delivers CertificateDecryptionKeySource and PdfCertificateEncryption. Proved by unit tests (several groups, several recipients per group, a certificate that matches no recipient, metadata clear); integration: M16's public-key fixtures open with their test key and match their twins; our encrypted outputs open in pyHanko and in Apache PDFBox with the recipient's key and not without it, with the permissions of the recipient's group. Leaves the heavy cases.
  10. Signed, encrypted and heavy documents. Delivers signing under a password (every string of the signature dictionary encrypted but /Contents), after a linearized file, after hybrid and relocated sections; the SigningBenchmarks with MemoryDiagnoser. Proved by the encrypted, linearized and heavy rows below. Leaves the tool.
  11. The tool, the documentation and the remote corpus. Delivers sign, encrypt, decrypt in the dotnet tool and the AOT binary, and the documentation. Proved by CorpusToolTests and a green Remote corpus run recorded in docs/status.md with its date.

The test PKI​

A fictitious PKI described in tests/pki/ as a Certomancer configuration — roots, intermediates, signers for every algorithm above (Ed25519 and Ed448 for the deferred path), a time-stamp authority, an OCSP responder and CRLs for M27, certificates short-lived enough for M27's expiry simulation — with its keys committed as test data, never used anywhere else, and the same PKI served by Certomancer in the referee container: the time-stamp authority for this milestone, the revocation services for M27. DSS and pyHanko trust its root only when a test says so.

Tests required​

Unit — tests/AdCodicem.Pdf.Tests, under Signing/:

  • The reservation: byte-range layout, the digest over exactly the covered bytes, completion once, a flush refused before completion.
  • The CMS: every attribute's DER, sorting, each algorithm, the self-check refusing a wrong signature, a wrong digest, a certificate that does not match the key.
  • Time stamps: every rejection, the nonce, MaxResponseLength, a token without the TSA's certificate.
  • Fields and seed values: each /SV entry required and optional, locks, a signed field, a field in a hostile tree (a cycle, a widget that is its own parent).
  • Certification: each P, each refusal; the guard's decisions for sequential signers.
  • Appearances: each kind under PDF/A-1, 2, 3 and 4 and PDF/UA-1.
  • Deferred: the request's JSON round trip, every tampering of the prepared file refused.
  • CSC: each endpoint's model against the specification's examples; every error mapped.
  • Encryption: seed and permissions for each group; the key derivation M16 performs agreeing with the encryption.
  • Determinism: with RSA PKCS #1 v1.5 and a fixed time, two signings give identical bytes; with PSS or ECDSA, identical outside the gap.
  • Cancellation and progress on hashing, per M03's property; a canceled signing leaves the output untouched.

Integration — tests/AdCodicem.Pdf.IntegrationTests, every referee in a container:

  • EU DSS — its validation library at a pinned release behind a small command-line main in a Java image, given the file, the trusted certificates, the validation time and DSS's default policy; it returns DSS's simple report, detailed report and ETSI validation report. The signature format (PAdES-BASELINE-B, -T), the indication and sub-indication are what the tests read.
  • pyHanko — validation under the test root with fetching off, field listing, modification levels, public-key decryption.
  • OpenSSL — cms -verify over the byte range; ts -reply -text over our tokens.
  • poppler's pdfsig — signed ranges of earlier signatures unchanged.
  • veraPDF — PDF/A and PDF/UA claims after signing.
  • qpdf — every signed file passes --check; Apache PDFBox — decrypting our certificate-encrypted outputs.
  • Certomancer — the test PKI's time-stamp authority and the CSC test service.

Acceptance conditions​

"Signed with the test PKI" means an invisible B-B signature by the RSA signer at the test time unless the row says otherwise; "DSS passes it" means DSS reports TOTAL_PASSED at the level claimed, at the test time, with the test root trusted.

DocumentsBehaviorVerified by
Every committed document the reader opens without a rebuilt index — encrypted ones with their recorded password — and without a certification that forbids itSigned with the test PKI: DSS passes it, pyHanko finds it intact and valid, qpdf accepts the file, the input is a byte-identical prefix; and the signed copy still passes the reading, validation (with the signature.* findings the new signature adds), round-trip and extraction acceptance tests the original passedCorpusSigningTests.Every_document_signs_and_still_passes_its_earlier_tests (new), DssSigningRefereeTests.Our_signatures_pass_at_the_level_claimed (new)
documents/contract/chromium-contract-fr.pdf, documents/invoice/chromium-invoice-fr.pdf, documents/archival/libreoffice-report-pdfa2b.pdfSigned at B-B and at B-T with each algorithm of the table the platform supports, and by an external Ed25519 signer through the deferred path: DSS reports PAdES-BASELINE-B or -T and passes each, the Ed25519 one included; adbe.pkcs7.detached passes in DSS as PKCS7-BDssSigningRefereeTests.Every_algorithm_and_level_passes (new)
The signed twin of the contract — M04's fixture, filled by M04 —, vendor/fr-licence-ouverte/fop-dictao-dila-signed-joafe-notice.pdf, vendor/pyhanko/acrobat-reader-signed-twice.pdf, vendor/lu-legilux/antenna-house-legilux-memorial-pades-lta.pdf, fop22-legilux-memorial-seal-renewed-timestamps.pdf, vendor/node-signpdf/skia-chrome74-node-signpdf-reason-contains-trailer.pdf (startxref a line early), vendor/us-federal/docusign-pdfkit-gsa-sf30-contract-modification.pdf (a FieldMDP lock)Countersigned in a new field: every earlier signature covers exactly the revision it covered, as M04 and pyHanko read it; pdfsig reads the same ranges; DSS gives each earlier signature the verdict it gave it on the input, and passes oursCorpusSigningTests.Signing_never_invalidates_an_earlier_signature (new), PdfsigRefereeTests.Earlier_ranges_are_unchanged (new)
vendor/us-federal/itext-govinfo-us-code-certified.pdf (DocMDP P=1)An approval signature refused by M04's guard; a document time stamp accepted, M04 classing it DocumentTimestamp, pyHanko and DSS finding the certification intact and the time stamp validCorpusSigningTests.A_p1_certification_admits_only_a_time_stamp (new)
Our certification of chromium-contract-fr.pdf at P 1, 2 and 3, then M16's fill of a field, an M11 highlight and a countersignatureEach allowed change written and each forbidden one refused, as the P says; pyHanko's modification level is the one the P allows; insisted on, a forbidden change is one pyHanko calls suspiciousCorpusSigningTests.Certifications_permit_what_their_level_permits (new)
A document with an approval signature (fop-dictao-dila-signed-joafe-notice.pdf)Certifying it is refused (CertificationRefused)CorpusSigningTests.A_certification_never_follows_an_approval (new)
Empty signature fields: vendor/fr-licence-ouverte/libreoffice-cerfa-13983-form.pdf (Signature1, Signature2), pdfmaker-acrobat-cerfa-12156-form.pdf, vendor/pikepdf/livecycle-dod-dd293-aes128-xfa.pdf (AES-128, usage rights, XFA hybrid); M16's and M17's placeholders on the generated contract, with /Lock and /SV; remote, remote/opm/word365-acrobat-opm-of306-form.pdf (two unsigned fields, linearized)Filled by M16 where they are forms, then signed in each empty field in turn: the widget keeps its rectangle and page, the lock becomes a FieldMDP reference pyHanko reads, required seed values are met or the signing refused; the usage-rights signature still covers its revision and any lost grant is reportedCorpusSigningTests.Empty_fields_sign_in_place_and_honor_their_seed_values (new)
vendor/node-signpdf/pdfkit-node-signpdf-unsigned-placeholder.pdf (a /ByteRange of names, a /Contents of zeros)Its field signed in a new revision, the old placeholder left untouched as M04 reads it; pyHanko and DSS pass the new signatureCorpusSigningTests.A_foreign_placeholder_is_signed_not_patched (new)
Every damaged/* document; remote, remote/eu-dss/dss-signed-widget-self-parent-startxref-past-eof.pdfSigning refused (HistoryUnreliable); insisted on, signed, reported, and the report names the revision the reader had to guessCorpusSigningTests.An_unreliable_history_is_not_signed_in_silence (new)
PDF/A: libreoffice-report-pdfa2b.pdf (2b), vendor/eu-publications/pdflib-oj-exchange-rates-greek.pdf (2a), vendor/verapdf/pdfa3b-embedded-pass.pdf (3b), vendor/zugferd/mustang-zugferd2-en16931-invoice.pdf (3u), vendor/verapdf/pdfa1b-annotations-pass.pdf (1b), vendor/verapdf/pdfa4-metadata-pass.pdf (4, PDF 2.0), and M14's own PDF/A-3 Factur-X invoicesSigned with a visible text-and-image appearance and with an invisible one: veraPDF upholds every claim; the contributed pdfa-signature rules agree with veraPDF on each; the invoice's XML attachment is unchanged, byte for byteVeraPdfSigningRefereeTests.Signing_keeps_every_pdfa_claim (new)
PDF/UA: vendor/pdf-association/pdflib-pps-kraxi-pdfa2a-pdfua1-invoice.pdf, indesign13-pdfua1-german-book-chapter.pdfSigned visibly: the widget in a Form element with /TU; veraPDF's PDF/UA-1 profile finds no failure the input did not haveVeraPdfSigningRefereeTests.Signing_keeps_a_pdfua_claim (new)
documents/secured/qpdf-invoice-aes256.pdf with its password; documents/archival/qpdf-linearized-report.pdf; documents/invoice/word-invoice-fr.pdf (a hybrid index and an empty update)Each signed: /Contents unencrypted and every other string of the signature dictionary encrypted, pyHanko and DSS passing the signature with the password; the linearized file signed in an update and reported no longer linearized; the hybrid file's update of the kind M03 writes after itCorpusSigningTests.Encrypted_linearized_and_hybrid_documents_sign (new)
chromium-contract-fr.pdf signed through the deferred flow completed in a second process; through the CSC test service at SCAL 1 and SCAL 2; through an RSA whose private operation runs out of processDSS passes each; a prepared file altered between the steps, and a CSC response whose signature the test altered, refused with nothing writtenCorpusSigningTests.Keys_held_elsewhere_sign_as_local_keys_do (new), CscSigningTests (new)
M16's public-key fixtures — adbe.pkcs7.s4 and s5, RC4-128, AES-128 and AES-256, with their test key, filled by M16 — and their twinsEach opens with its key through CertificateDecryptionKeySource and matches its twin: page count, object graph, extracted textCorpusCertificateEncryptionTests.Certificate_encrypted_documents_decrypt_and_match_their_twins (new)
chromium-contract-fr.pdf and libreoffice-report-pdfa2b.pdf, encrypted by us for two recipient groups with different permissionsEach recipient opens the file in pyHanko and in PDFBox with its own key and permissions; a certificate outside the groups cannot; our reader agreesPdfBoxSecurityRefereeTests.Our_certificate_encryption_opens_for_its_recipients (new)
documents/stress/reportlab-journal-1000-pages.pdf; remote, remote/govinfo/us-code-2023-title42.pdf (9,302 pages, after the GPO's signature)Signed at B-T with memory flat — the original read once, never held —, within the budget recorded in status.md; the GPO's signature still covers its revisionCorpusSigningTests.Signing_a_long_document_holds_its_budget (new), SigningBenchmarks (new)
Any committed document, RSA PKCS #1 v1.5, a fixed time and a fixed tokenTwo signings byte-identicalCorpusSigningTests.Signing_is_deterministic_given_its_inputs (new)
The same operations through the toolThe AOT binary writes what the API writesCorpusToolTests.Sign_encrypt_and_decrypt_match_the_api (new)

The remote rows close only on a green Remote corpus run, recorded in docs/status.md with its date.

Corpus​

What the corpus holds​

  • Signed documents to countersign without breaking: the seven committed signed documents M04 names — a GPO certification at P=1 with a lock, DILA's Dictao signature, Acrobat Reader's two signatures, node-signpdf's signature after a startxref a line early, Luxembourg's PAdES B-LTA and its seal renewed by two document time stamps, DocuSign's seal with a FieldMDP lock over a JBIG2 scan —, and some thirty remote ones (signed-*, pades-b-*, certification-signature, document-timestamp, multiple-signatures, many-signatures).
  • Fields waiting for a signature: unsigned-signature-field in Cerfa 12156 and the OPM OF-306 (remote), Cerfa 13983's two fields, DD 293's; the PDFKit placeholder (unsigned-signature-placeholder, byterange-name-placeholders); usage rights (usage-rights-ur3, ur3-usage-rights).
  • Claims to keep: PDF/A-1b, 2a, 2b, 3b, 3u and 4 documents veraPDF upholds, PDF/UA-1 documents, a Factur-X invoice's XML.
  • What the writer must follow: an encrypted invoice, a linearized report, a hybrid index with an empty update, a signed document under AES-128 (remote), relocated startxref lines.
  • Scale: the 1000-page journal; the 9,302-page US Code, signed, remote.

What it lacks​

NeedWhyPriorityLikely source
A signed twin of our contractThe roadmap's acceptance names the contract's signed twin, which M04 adds; documents/contract/chromium-contract-fr.pdf is unsigned — M04's priority-1 gap, M03's at priority 21Filled by M04 (pyHanko over the committed contract); M26 cannot close without it
Documents encrypted for certificates (adbe.pkcs7.s4, s5; RC4-128, AES-128, AES-256), their test key and their twinsThe roadmap's third acceptance condition has no document; M16 lists the same gap at priority 11Filled by M16 (pyHanko's public-key handler over the fictitious test PKI); if M16 has not, generated here the same way
The test PKI: Certomancer's configuration, certificates and keys for every algorithm, a time-stamp authority, revocation servicesEvery signature this milestone writes needs a key and a root DSS and pyHanko can be told to trust; it is test data under tests/pki/, not a corpus document1Generated here with Certomancer, pinned in requirements.txt
Our own signed outputs, committed: B-B and B-T over the invoice, the contract and the PDF/A report; a certified contract at P=2 with a countersignature; a document-time-stamped case file from M18; a signed PDF/A-3 Factur-X invoice"What we produce must be as readable as what we consume": M27 validates them, M20 judges a signed PDF/A document, which its own gap asks for1Generated here, by this milestone, recorded in build_corpus.py with the test PKI's time and token
A third-party empty signature field carrying /SV seed values and a /Lock, from AcrobatSeed values are proven only on fields our own M16 writes2A contribution (W27: an Acrobat-prepared form); generated here with pyHanko's seed-value support as a stand-in
A certificate-encrypted document written by AcrobatM16's fixtures are pyHanko's; Acrobat is the writer the documents in the wild come from2A contribution (W27)
A document signed with ECDSA on a Brainpool curve by a European eID cardThe curves are offered where the platform has them, and no real file shows one3A contribution (W27); generated here from the test PKI as a stand-in

Traps​

  • An incremental update is the only way to sign. A full rewrite moves every earlier signature's bytes; M03 refuses it, and so does every path here.
  • /Contents must fit before it exists. The reserve is fixed when the byte range is laid out; the CMS and its token come after. Measure the reserve, refuse an overflow with the size needed, never truncate, never ask a remote signer twice in silence.
  • Hash the original once, streaming. Loading a 9,302-page file to sign it defeats invariant 2.
  • Signed attributes are a DER SET OF, sorted. node-signpdf's unsorted set is in the corpus; validators differ on it.
  • PAdES has its own rules on signing-time, /Reason beside a commitment type, and the certificate attribute; a CAdES signature that ignores them validates as CMS and fails as PAdES.
  • A certification comes first. After an approval signature it is refused, not written.
  • /M is the caller's time. Reading the clock makes the same inputs give different bytes, and a server's clock is not the signer's; the tool makes --time now explicit.
  • A service may sign the wrong thing. A CSC service, an HSM or a two-step completion can return a valid signature over another digest. The self-check before writing is what catches it.
  • The prepared file of a two-step signature can change between the steps; check its digest and byte range, then patch exactly the gap.
  • Under encryption, /Contents stays clear and every other string of the signature dictionary is encrypted.
  • A time-stamp URL in /SV is text. The library calls no server the caller did not supply.
  • ECDSA and PSS are randomized. Determinism holds outside the gap; the tests compare accordingly.
  • The ESIC extension: an ETSI sub-filter or a document time stamp in a 1.7 file needs its /Extensions entry, unioned with what is there (ADR 40); in 2.0 it is native.
  • A visible signature is an annotation under PDF/A and PDF/UA: embedded fonts, the Print flag, a tagged widget with /TU — or the seal voids the invoice's claims.

Documentation​

  • docs/website/docs/guides/signing.md (new): signing a document, choosing the level, fields and seed values, appearances, certification, sequential signers, document time stamps.
  • docs/website/docs/guides/remote-signing.md (new): keys in an HSM, the two-step flow, the CSC API, with a PKCS #11 adapter as an example.
  • docs/website/docs/concepts/signatures.md (new): what a PAdES signature is made of, levels, what the library claims and does not (qualification, validation — M27), determinism, network off by default.
  • docs/website/docs/concepts/security-handlers.md: the public-key handler, opening and writing.
  • docs/website/docs/concepts/revisions-and-signatures.md: M04's page, linked to signing.
  • docs/website/docs/reference/tool/: sign, encrypt, decrypt.
  • docs/website/docs/reference/validation-rules.md: the pdfa-signature rules contributed by the satellite.
  • docs/website/docs/introduction.md and docs/features/features.json: the signing entry brought to its state.
  • docs/architecture.md: the Signing satellite's content, the core's reservation and patch, the test PKI.
  • docs/corpus.md and docs/corpus-contributions.md: our signed outputs, the test PKI, the new wants.
  • docs/status.md: the reserve's default, the budgets, the measurements.

Exit criteria​

  • The satellite signs at B-B and B-T with the local signer, an out-of-process key, the two-step flow and the CSC API, each signature self-checked before it is written.
  • Document time stamps, certification, locks, seed values and sequential signers work through M04's guard.
  • Visible appearances keep PDF/A and PDF/UA claims; the pdfa-signature CMS rules are contributed to M20.
  • Documents encrypted for certificates open and are written.
  • The priority-1 gaps above are filled — M04's and M16's by them —; each remaining gap is recorded in docs/corpus-contributions.md.
  • The acceptance conditions above pass on the corpus, in CI, with no document skipped, and the remote rows on a green Remote corpus run recorded in docs/status.md.
  • Unit tests cover each behavior, its degenerate cases and its hostile ones; the FsCheck property holds.
  • Integration tests run EU DSS, pyHanko, OpenSSL, pdfsig, veraPDF, qpdf, PDFBox and Certomancer, each in a container.
  • SigningBenchmarks runs with MemoryDiagnoser; docs/status.md records the budgets.
  • sign, encrypt and decrypt ship in the dotnet tool and the AOT binary, documented.
  • The documentation site publishes the pages listed above.
  • Every page of Documentation is written in its Diátaxis section, one mode per page (ADR 47).