A signature time-stamp is an RFC 3161 token from a time-stamp authority that proves the signature existed at a given time, so it can be checked after the signing certificate expires. With RecursiveAI.OneCrypto.Pdf, set Level = PadesLevel.Timestamped and pass a function that sends the time-stamp request to an authority. The program below signs the IRS Form W-9 and gets the time-stamp from freetsa.org, a free public authority, over RecursiveAI.OneCrypto.Online.
dotnet new console -o PdfTimestamp && cd PdfTimestamp dotnet add package RecursiveAI.OneCrypto.Pdf --version 0.2.0-preview.1 dotnet add package RecursiveAI.OneCrypto.Online --version 0.2.0-preview.1 curl -LO https://www.irs.gov/pub/irs-pdf/fw9.pdf curl -O https://freetsa.org/files/cacert.pem -O https://freetsa.org/files/tsa.crt openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 365 \ -subj "/CN=Ada Lovelace" -addext "basicConstraints=critical,CA:FALSE" \ -addext "keyUsage=critical,digitalSignature,nonRepudiation" \ -keyout signer-key.pem -out signer.pem
OneCrypto is a preview, so dotnet add package needs --version or --prerelease. The OpenSSL line makes a self-signed test certificate; use your own certificate and key for real documents. Put this in Program.cs and run dotnet run:
using OnePdf; using RecursiveAI.OneCrypto; using RecursiveAI.OneCrypto.Online; using RecursiveAI.OneCrypto.Pdf; using RecursiveAI.OneCrypto.Validation; using LoadedItems signerFile = Crypto.Load("signer.pem"); using LoadedItems keyFile = Crypto.Load("signer-key.pem"); Certificate signer = signerFile.Certificates[0]; // The time-stamp function: POST the request to the authority, return its reply var fetcher = new Fetcher(new HttpTransport(new()), new FetchOptions()); PadesTimestampFunction freeTsa = async (request, ct) => { FetchResult r = await fetcher.FetchAsync(new FetchRequest { Uri = new Uri("https://freetsa.org/tsr"), Method = "POST", Body = request.Encoded, Headers = [new("Content-Type", "application/timestamp-query")], }, ct); return r.IsSuccess ? r.Body.ToArray() : throw new IOException(r.Reason); }; using (PdfDocument doc = PdfDocument.Open("fw9.pdf", new PdfOpenOptions { AllowEditing = true })) using (FileStream output = File.Create("fw9-timestamped.pdf")) { PadesSignResult result = doc.SignPades(output, keyFile.PrivateKeys[0].Signing!, signer, [], new PadesSignOptions { Level = PadesLevel.Timestamped, Timestamp = freeTsa, }); Console.WriteLine($"Signed {result.FieldName}: {result.Level}"); } // Verify, trusting the test signer and freetsa.org's root using LoadedItems tsaRootFile = Crypto.Load("cacert.pem"); Certificate tsaRoot = tsaRootFile.Certificates[0]; using PdfDocument signedDoc = PdfDocument.Open("fw9-timestamped.pdf"); PadesReport report = signedDoc.VerifyPades(new PadesVerifyOptions { TrustAnchors = [TrustAnchor.FromCertificate(signer), TrustAnchor.FromNameAndKey(tsaRoot.Subject, tsaRoot.PublicKey)], ReferenceTime = DateTimeOffset.UtcNow, }); PadesSignatureReport s = report.Signatures[0]; PadesTimestamp stamp = s.Timestamps[0]; Console.WriteLine($"Valid: {s.IsValid}, level: {s.Level}"); Console.WriteLine($"Time-stamp: {stamp.GenTime:u}, trusted: {stamp.IsTrusted}"); Console.WriteLine($"Proof of existence: {s.ProofOfExistence:u}"); // For openssl ts -verify: the token and the signature value it covers File.WriteAllBytes("timestamp.tsr", stamp.Report!.Token!.Encoded.ToArray()); File.WriteAllBytes("signature.bin", s.Cms!.Signers[0].Signer.Signature.ToArray());
Signed Signature1: Timestamped Valid: True, level: Timestamped Time-stamp: 2026-11-01 08:35:30Z, trusted: True Proof of existence: 2026-11-01 08:35:30Z
The token sits inside the signature, as an unsigned attribute of its CMS, in the room the signature reserved. The verifier checks every RFC 3161 rule, that the token covers this signature’s value and that the authority chains to a trust anchor at the token’s time. Then it judges the signature at that time instead of the reference time: that is the proof of existence. The signing time written in the PDF is only a claim and is never used.
Check it with OpenSSL
openssl ts -verify checks the same token against the signature value it stamps:
openssl ts -verify -data signature.bin -in timestamp.tsr -token_in \ -CAfile cacert.pem -untrusted tsa.crt 2>/dev/null openssl ts -reply -in timestamp.tsr -token_in -token_out -text 2>/dev/null \ | grep -E "Policy|Hash Algorithm|Time stamp"
Verification: OK Policy OID: tsa_policy1 Hash Algorithm: sha256 Time stamp: Nov 1 08:35:30 2026 GMT
Choosing the authority
- freetsa.org is free, allows commercial use and asks for at most one request per second per address. It runs on a best-effort basis with no service-level agreement: fine for trying this out, but use an authority with an agreement for production. Each signature makes one request.
- Any RFC 3161 authority works the same way: change the URL, and add its API key to
Headersif it needs one. The fetcher refuses non-HTTP(S) addresses, applies a time limit and a size limit, and drops added headers when a redirect leaves the host. - An authority as a value. The time-stamp function only turns request bytes into response bytes, so a recorded reply, an in-house authority or OneCrypto’s own
TimeStampAuthorityhandler (used in the LTV recipe) plug in the same way.
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.
- freetsa.org’s root as a trust anchor. Its root certificate doesn’t mark basicConstraints critical, and by default OneCrypto’s path validator checks the form of an anchor certificate too, so it refuses this one. The program passes it as a name and key instead (
TrustAnchor.FromNameAndKey), which is how RFC 5280 defines a trust anchor. The validator is a preview: “still a preview, so not yet recommended for trust decisions” (Path validation). - A time-stamp is not long-term validation. B-T proves when the signature existed. To keep it verifiable after the certificates expire, add the revocation data and a document time-stamp (B-LTA): Sign a PDF with long-term validation.
- Revocation stays unknown. Nothing here fetches revocation data, so the chain check reports it as unknown, a warning, never as good.
How time-stamps are verified: RFC 3161 time-stamps. The fetcher, its guards and the clients: Online.