Recursive AI

Sign a PDF with long-term validation (PAdES LTV) in C#

All onecrypto recipes

A long-term validation (LTV) signature carries what a verifier needs years later: a time-stamp that proves when it was signed, the certificates and revocation data of its chain, and a document time-stamp over all of it. RecursiveAI.OneCrypto.Pdf writes PAdES from B-B to B-LTA on top of OnePDF. The program below signs the IRS Form W-9 with a time-stamp (B-T), then raises it to B-LTA with PadesAugmenter, and verifies the result.

Terminal
dotnet new console -o PadesLtv && cd PadesLtv
dotnet add package RecursiveAI.OneCrypto.Pdf --version 0.2.0-preview.1
curl -LO https://www.irs.gov/pub/irs-pdf/fw9.pdf

OneCrypto is a preview, so dotnet add package needs --version or --prerelease; the package brings OnePDF 1.17.1 and the core RecursiveAI.OneCrypto with it. So that it runs anywhere, the program makes a throwaway certificate authority, signer and time-stamp authority with OneCrypto itself, and the CA issues the revocation list. Put this in Program.cs and run dotnet run:

C#
using System.Security.Cryptography;
using OnePdf;
using RecursiveAI.OneCrypto;
using RecursiveAI.OneCrypto.Issuance;
using RecursiveAI.OneCrypto.Keys;
using RecursiveAI.OneCrypto.Pdf;
using RecursiveAI.OneCrypto.Tsp;
using RecursiveAI.OneCrypto.Validation;
using RecursiveAI.OneCrypto.X509;
 
// Test stand-ins for your certificate, your CA's CRL and a time-stamp authority
var pki = new TestPki();
TrustAnchor[] anchors = [TrustAnchor.FromCertificate(pki.Root)];
File.WriteAllText("root.pem", pki.Root.ToPem());
 
// 1. Sign with a signature time-stamp: PAdES B-T
using (PdfDocument doc = PdfDocument.Open("fw9.pdf",
    new PdfOpenOptions { AllowEditing = true }))
using (FileStream output = File.Create("fw9-signed.pdf"))
{
    doc.SignPades(output, pki.SignerKey, pki.Signer, [pki.Root], new PadesSignOptions
    {
        Level = PadesLevel.Timestamped,
        Timestamp = pki.Timestamp,
    });
}
 
// 2. Afterwards, add revocation data issued after the time-stamp, then a document
//    time-stamp over everything: PAdES B-LTA
DateTimeOffset now = DateTimeOffset.UtcNow;
byte[] lta = PadesAugmenter.Augment(File.ReadAllBytes("fw9-signed.pdf"),
    new PadesAugmentOptions
    {
        Level = PadesLevel.LongTermArchive,
        Timestamp = pki.Timestamp,
        ValidationData = new PadesValidationData
        {
            TrustAnchors = anchors,
            Revocation = new RevocationData { Crls = [pki.IssueCrl(now)] },
            ReferenceTime = now,
        },
    });
File.WriteAllBytes("fw9-lta.pdf", lta);
 
// 3. Verify what the file now carries
using PdfDocument signedDoc = PdfDocument.Open("fw9-lta.pdf");
PadesReport report = signedDoc.VerifyPades(new PadesVerifyOptions
{
    TrustAnchors = anchors,
    ReferenceTime = DateTimeOffset.UtcNow,
});
DssSummary dss = report.Dss;
Console.WriteLine($"Store: {dss.Certificates} certificates, {dss.Crls} CRL, " +
    $"{dss.OcspResponses} OCSP responses");
foreach (PadesSignatureReport s in report.Signatures)
    Console.WriteLine(s.Kind == PadesSignatureKind.Signature
        ? $"{s.FieldName}: valid={s.IsValid}, level={s.Level}, " +
          $"revocation={s.Chain!.RevocationSummary}"
        : $"{s.FieldName}: document time-stamp, valid={s.IsValid}");
 
// A throwaway PKI made with OneCrypto. Nothing here is a real key.
sealed class TestPki
{
    public readonly Certificate Root, Signer;
    public readonly ISigningKey SignerKey;
    readonly CertificateAuthority ca;
    readonly TimeStampAuthority tsa;
 
    public TestPki()
    {
        DateTimeOffset start = DateTimeOffset.UtcNow.AddHours(-1);
        var rootKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        ca = CertificateAuthority.CreateRoot(Name("Test Root CA"),
            X509Signer.FromECDsa(rootKey, null), start, TimeSpan.FromDays(3650),
            new(), CertificateProfile.Root);
        Root = ca.Certificate;
 
        var signerKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        SignerKey = PrivateKey.FromECDsa(signerKey, true);
        Signer = ca.Issue(PublicKeyInfo.FromAsymmetricAlgorithm(signerKey),
            Name("Ada Lovelace"), new CertificateIssueOptions
            {
                NotBefore = start,
                Validity = TimeSpan.FromDays(365),
                Profile = new CertificateProfile
                {
                    Name = "Document signing",
                    KeyUsages = KeyUsages.DigitalSignature | KeyUsages.NonRepudiation,
                    MaxValidity = TimeSpan.FromDays(825),
                },
            });
 
        var tsaKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        Certificate tsaCert = ca.Issue(PublicKeyInfo.FromAsymmetricAlgorithm(tsaKey),
            Name("Test Time-Stamp Authority"), new CertificateIssueOptions
            {
                NotBefore = start,
                Validity = TimeSpan.FromDays(365),
                Profile = CertificateProfile.TimeStamping,
            });
        tsa = new TimeStampAuthority(new TimeStampAuthorityOptions
        {
            Certificate = tsaCert,
            Signer = X509Signer.FromECDsa(tsaKey, null),
            DefaultPolicy = "1.3.6.1.4.1.99999.1",
        }, new RandomTimeStampSerialSource());
    }
 
    // A time-stamp function: request bytes in, response bytes out
    public ValueTask<byte[]> Timestamp(TimeStampRequest req, CancellationToken ct) =>
        ValueTask.FromResult(tsa.Handle(req.Encoded, DateTimeOffset.UtcNow)
            .Encoded.ToArray());
 
    public CertificateRevocationList IssueCrl(DateTimeOffset at) =>
        ca.IssueCrl(new CrlIssueOptions
        {
            ThisUpdate = at, NextUpdate = at.AddDays(7), Number = 1,
        });
 
    static DistinguishedName Name(string cn) =>
        DistinguishedName.Create([(KnownOids.CommonName, cn)]);
}
Text
Store: 2 certificates, 1 CRL, 0 OCSP responses
Signature1: valid=True, level=LongTermArchive, revocation=Good
Timestamp1: document time-stamp, valid=True

fw9-lta.pdf holds three revisions: the signature with its time-stamp inside, the document security store (DSS) with the certificates and the CRL, and the document time-stamp. The verifier reports LongTermArchive only when it can prove each step from the file: a trusted time-stamp, revocation status “good” from the data the file carries, for the signer and for the time-stamp authority, and a trusted document time-stamp made after that data. Revocation data the verifier is given in its options doesn’t count towards the level.

Where the revocation data comes from

Here it is a CRL the test CA issues on the spot, passed as data: nothing is fetched. For a certificate from a real CA, set ValidationData.RevocationSource to OnlineRevocationSource from RecursiveAI.OneCrypto.Online; it fetches the OCSP responses or CRLs named in the certificates. For a real time-stamp authority, see Add a timestamp to a PDF signature.

The revocation data has to be issued after the signature time-stamp. The verifier judges the signature at the time the time-stamp proves, and a CRL issued before that time can’t show the certificate was still good then. That is why the program signs first and augments afterwards.

Check it with pyHanko

pyHanko has an ETSI AdES validator that requires revocation data. The test certificates name no CRL or OCSP address, so it can use only what the file carries. It accepts fw9-lta.pdf, and rejects the B-T file from step 1, which has none:

Terminal
python3 -m venv venv && venv/bin/pip install -q pyhanko pyhanko-cli
venv/bin/pyhanko sign adesverify --trust-replace --trust root.pem \
  --executive-summary fw9-lta.pdf 2>/dev/null
venv/bin/pyhanko sign adesverify --trust-replace --trust root.pem \
  --executive-summary fw9-signed.pdf 2>/dev/null
Text
Signature1:ffdd1f0095c939880f166cd2de4bf0a000906406033d0c358d91a5766d4d7eea:VALID
Signature1:ffdd1f0095c939880f166cd2de4bf0a000906406033d0c358d91a5766d4d7eea:INVALID

Limits

  • Not audited. OneCrypto (cryptography, not cryptocurrency) has not been independently audited. It is a preview: 0.2.0-preview.1, and the API can change.
  • What “valid” covers. The level and verdict say what was checked against the trust anchors you gave: the signature, the time-stamps, the chain and the revocation data in the file. They don’t say a signature is fit for any legal use. The path validator behind the chain check is a preview, in the project’s words “still a preview, so not yet recommended for trust decisions” (Path validation).
  • Nothing is fetched unless you ask. Revocation data and time-stamps come from the data, functions and sources you pass. With no data, revocation stays unknown, never good, and the level stops at B-T.
  • Readers. For RSA and ECDSA signatures, OnePDF and the bridge must both accept them. The project checks the embedded CMS and time-stamps with OpenSSL and the files with poppler’s pdfsig 26.01.0, which agrees. Its conformance table lists “Adobe Acrobat Reader: pending”, so there is no claim yet that Acrobat accepts these signatures.
  • Room. A signature reserves 12 KiB for its CMS by default, enough to time-stamp it later. If an augment step doesn’t fit, it raises PadesException with PadesError.NoRoom.

How the levels, the store and the time model work: PDF signatures. To check a signed file, see Verify a PDF signature in C#.