---
title: "Sign a PDF with long-term validation (PAdES LTV) in C#"
url: https://recursiveai.org/onecrypto/cookbook/pades-ltv-pdf-signature/
description: "PAdES signature in C# with long-term validation: sign a PDF with a time-stamp, then embed the revocation data and a document time-stamp (PAdES B-LTA). Checked with pyHanko. Runnable code with OneCrypto.Pdf 0.2.0-preview.1."
version: "0.2.0-preview.1"
---

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

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.

```shell
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`:

```csharp
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](https://recursiveai.org/onecrypto/cookbook/timestamp-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](https://github.com/MatthiasValvekens/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:

```shell
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](https://recursiveai.org/onecrypto/docs/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](https://recursiveai.org/onecrypto/docs/pdf/). To check a signed file, see [Verify a PDF signature in C#](https://recursiveai.org/onecrypto/cookbook/verify-pdf-signature/).
