Signing bytes is a solved problem, and has been for decades, so if all you want is a CMS blob over a hash, any crypto toolkit from the last twenty years will do it in an afternoon, which is exactly why the interesting question in PDF signing is never the cryptography, it is everything around the cryptography: the signature has to ride inside a file format from 1993 without disturbing a single byte that was already there, it has to keep verifying after the certificate that made it has expired, and it has to convince a reader written by somebody else, on a different continent, in a different decade, that the document is exactly what it claims to be.

That gap between “I signed some bytes” and “a stranger’s software trusts this document” is where underskrift lives, a Rust library for signing and verifying PDFs at every PAdES conformance level from B-B through B-LTA, and building it taught us more about the PDF specification than about any elliptic curve, so this is the post about what it actually takes to ship a compliant signature library rather than a demo that signs its own output and calls it done.

The levels are about evidence, not strength

The PAdES levels confuse people because they sound like grades of cryptographic quality and they are not, they are grades of evidence preservation, and the difference matters the day a signature gets checked after the world has moved on. B-B is the baseline, a CMS signature over the document hash with the signing certificate attached; B-T adds a trusted timestamp from an RFC 3161 time stamping authority, because certificates expire and “was it signed while the cert was valid” needs a neutral witness; B-LT embeds the long-term validation material directly into the document, the certificate chain, the OCSP responses, the CRLs, all folded into the Document Security Store, so a verifier ten years from now does not have to go hunting for revocation data that may no longer be served; and B-LTA layers archive timestamps over the whole thing at intervals, so the evidence itself stays provably intact even as algorithms age out of fashion.

A contract signed today gets argued about in 2036, and the court’s tooling will not care that your RSA key was fine in 2026, it will care whether the document carries everything needed to re-run the validation with no network and no cooperation from you, which is why underskrift treats the timestamp client, the OCSP and CRL fetchers, and the DSS embedding as first-class features rather than appendix material.

The signing side of that is a few lines:

use underskrift::{PdfSigner, SigningOptions, SoftwareSigner, SubFilter};

let pdf_data = std::fs::read("document.pdf")?;
let signer = SoftwareSigner::from_pkcs12_file("key.p12", "password")?;

let options = SigningOptions {
    sub_filter: SubFilter::Pades,
    field_name: "Signature1".to_string(),
    reason: Some("Approved".to_string()),
    ..Default::default()
};

let signed = PdfSigner::new()
    .options(options)
    .sign(&pdf_data, &signer)
    .await?;

A PDF signature is an append, not a rewrite

The mechanism that makes all of this work is the incremental update, and it is the piece nobody warns you about: a signed PDF is the original file, byte for byte, with a new revision glued onto the end, and the signature’s ByteRange covers everything except the gap where the signature value itself lives, so you are performing surgery on a serialized object graph where re-serializing anything invalidates the very thing you are protecting.

This is where the format shows its age and its teeth, because a modern PDF rarely ends in a classic cross-reference table, it uses cross-reference streams, and an incremental writer that appends a classic table pointing back at a stream produces a file that lenient readers accept and strict readers reject, while forgetting to carry the trailer’s document ID or encryption dictionary forward corrupts encrypted inputs outright, none of which you will ever discover if your only test reader is your own parser.

Self-consistent is not interoperable

That last sentence is the thesis of the whole project, and we learned it the structured way, by auditing underskrift against the EU’s DSS reference implementation and writing up the findings as an architecture decision record, because the audit surfaced a whole cluster of defects where the library produced output that was perfectly self-consistent and completely non-interoperable: it signed files that its own verifier blessed while the reference tooling turned them away.

The concrete failures are worth listing because they are the kind that only cross-implementation testing finds. Requesting a B-LTA signature silently produced a plain B-B one, with no error, which is the worst failure mode a security library can have; the fix was to honor the requested level and fail loudly with a configuration error when the inputs, like a missing timestamp authority, make the requested level impossible. ByteRange verification did not bind the unsigned gap to the parsed signature contents, so a document could carry extra bytes past the final signature without tripping an integrity failure. The ECDSA signature OID was keyed off the curve instead of the digest, which mis-encoded every non-default pairing and all of the SHA-3 combinations. And the SVT validator accepted any certificate chain embedded in the token, trusted or not, which turned a trust decision into a shrug.

Every one of those is now pinned by a regression test, and the standing rule that fell out of the exercise is the one we would hand to anyone building in this space: your own verifier passing means nothing at all, conformance is measured against somebody else’s reader, so validate with DSS and poppler’s pdfsig before you believe a word of your own output.

When the key never leaves the hardware

Real deployments rarely let you touch the private key, because the key lives in an HSM, a cloud KMS, or a smart card, and the whole point of that hardware is that the key never leaves it, so underskrift splits signing into three phases: prepare the document and get back the hash that needs signing, hand that hash to whatever external signer you have, then finalize by injecting the returned signature into the prepared PDF.

// Phase 1: prepare, returns the hash to be signed externally
let prepared = prepare_signature(&pdf_data, &signer_info, &options)?;

// Phase 2: sign externally, your HSM / KMS / smart card
let signature_bytes = your_external_signer.sign(&prepared.attrs_hash)?;

// Phase 3: finalize, inject the signature into the PDF
let signed_pdf = finalize_signature(prepared, &signature_bytes)?;

The same philosophy sits behind the CryptoSigner trait, which lets you bring your own signer for anything the built-in software signer does not cover, while the built-in one handles the everyday key formats, PKCS#12, PEM, and DER, across RSA PKCS#1 v1.5, RSA-PSS, ECDSA on P-256 through P-521, and Ed25519, with SHA-2 and SHA-3 digests.

The plumbing nobody demos

Past the headline features sits the infrastructure that a validation ecosystem actually runs on, and underskrift ships it rather than gesturing at it: RFC 9321 Signature Validation Tokens, the JWTs a validation service issues so a relying party can prove later that a signature checked out, issued, validated, and embeddable as document timestamps; ETSI TS 119 102-2 XML validation reports, so the result of a verification run is a standardized artifact instead of a boolean and a feeling; and SACI AuthnContext parsing from RFC 7773, which exists because the Swedish e-signing infrastructure encodes how authentication happened into a certificate extension, and yes, underskrift is Swedish for signature, so that one was personal.

Verification gets the same depth as signing, with configurable trust stores, a policy framework that does revocation checking with grace periods, and certificate chain validation that fails closed, because a verifier that defaults to permissive is a verifier that will eventually bless something it should not.

What it costs

The honest tradeoffs start with the dependency surface, since the network features, timestamping, OCSP, CRL fetching, pull in an HTTP client and an async runtime, and not every signer is a long-running service, so the library is cut into feature flags and a batch tool that only needs to sign can drop tsp, ltv, and the rest and keep a small synchronous build, with sync wrappers available through the blocking feature for the callers who want the full stack without an executor in their process.

The crypto comes from the RustCrypto ecosystem and the PDF layer is lopdf, so there are no OpenSSL bindings and no JVM anywhere in the path, which is a sentence you could not have written about this problem domain not that long ago, when the realistic options were a Java toolkit with a license you had to read twice or a C library with a build system from the Mesozoic. The version number is 0.1, and that is a deliberate honesty signal rather than an oversight, the API can still move, so pin the crate and read the changelog, the way you would with any young library that touches legal evidence.

It is BSD-2-Clause and on crates.io, because a signature library is the kind of infrastructure that only earns trust when everyone can read every line of it, and the test fixtures regenerate from a script, so the whole thing builds and verifies on a clean checkout, which circles back to the opening point: the cryptography was the easy afternoon, and the two decades of accumulated PDF archaeology, the interoperability audits, and the evidence preservation were the work, as usual.