Recursive AI

Sign a PDF with an external signature, HSM or remote signer in C#

All onepdf recipes

When the key lives in an HSM or a remote signing service that returns a whole CMS (PKCS#7) signature, sign in two steps. AddSignaturePlaceholder writes the signature field with an empty hole and gives you the digest to send; PdfSignatureHole.Fill writes the CMS that comes back into the hole. The second step needs only the file and the hole’s two offsets, so it can run later, in another process or on another machine.

Terminal
dotnet new console -o SignExternal && cd SignExternal
dotnet add package RecursiveAI.OnePdf --version 1.16.0
curl -LO https://www.irs.gov/pub/irs-pdf/fw9.pdf

Put this in Program.cs. SignDigest stands in for your signer, with a self-signed test certificate, so that it runs anywhere:

C#
using System.Formats.Asn1;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using OnePdf;
 
if (args is ["prepare"])
{
    // Step 1: add the signature field, with an empty hole where the CMS goes.
    using PdfDocument doc = PdfDocument.Open("fw9.pdf",
        new PdfOpenOptions { AllowEditing = true });
    PdfSignaturePlaceholder p = doc.AddSignaturePlaceholder("fw9-signed.pdf",
        new PdfSigningOptions
        {
            FieldName = "Signature1",
            SignerName = "Ada Lovelace",
            Reason = "I certify the information above",
            PageIndex = 0,
            Rectangle = new PdfRectangle(120, 192, 380, 216),
        });
    // The two offsets are all that step 2 needs.
    File.WriteAllText("hole.txt", $"{p.HoleStart} {p.HoleEnd}");
    Console.WriteLine($"Hole for the CMS: bytes {p.HoleStart} to {p.HoleEnd}");
    Console.WriteLine($"Digest: {Convert.ToHexString(p.Digest)}");
}
else if (args is ["complete"])
{
    // Step 2, later or in another process: hash the file around the hole ...
    long[] hole = File.ReadAllText("hole.txt").Split(' ').Select(long.Parse).ToArray();
    byte[] digest;
    using (FileStream file = File.OpenRead("fw9-signed.pdf"))
        digest = PdfSignatureHole.ComputeDigest(file, hole[0], hole[1],
            HashAlgorithmName.SHA256);
    Console.WriteLine($"Digest: {Convert.ToHexString(digest)}");
 
    // ... send the digest to the signer and get a detached CMS back ...
    using X509Certificate2 signer = TestCertificate("CN=Ada Lovelace");
    byte[] cms = SignDigest(digest, signer);
    Console.WriteLine($"CMS from the signer: {cms.Length} bytes");
 
    // ... and write it into the hole. Nothing else in the file moves.
    PdfSignatureHole.Fill("fw9-signed.pdf", hole[0], hole[1], cms);
 
    // Verify. Trust is explicit: here the test certificate is its own root.
    using PdfDocument signed = PdfDocument.Open("fw9-signed.pdf");
    var options = new PdfSignatureVerifyOptions { TrustAnchors = [signer] };
    foreach (PdfSignatureVerification v in signed.VerifySignatures(options).Signatures)
        Console.WriteLine($"{v.Signature.FieldName}: valid={v.IsValid}, "
                        + $"chain={v.Chain}, coverage={v.Coverage}");
 
    // The bytes the signature covers: the whole file minus the hole.
    using Stream data = signed.GetSignatures()[0].OpenSignedData()!;
    bool same = SHA256.HashData(data).SequenceEqual(digest);
    Console.WriteLine($"Signed bytes: {data.Length}, hash matches the digest: {same}");
}
 
// Stand-in for your HSM or signing service: digest in, detached CMS out.
// It signs the CAdES signed attributes (content type, message digest and
// signing certificate) with the certificate's RSA key.
static byte[] SignDigest(byte[] digest, X509Certificate2 cert)
{
    const string Data = "1.2.840.113549.1.7.1", Sha256 = "2.16.840.1.101.3.4.2.1";
    var a = new AsnWriter(AsnEncodingRules.DER);
    using (a.PushSetOf())
    {
        Attribute(a, "1.2.840.113549.1.9.3", w => w.WriteObjectIdentifier(Data));
        Attribute(a, "1.2.840.113549.1.9.4", w => w.WriteOctetString(digest));
        Attribute(a, "1.2.840.113549.1.9.16.2.47", w => // signing certificate v2
        {
            using (w.PushSequence()) using (w.PushSequence()) using (w.PushSequence())
                w.WriteOctetString(SHA256.HashData(cert.RawData));
        });
    }
    byte[] attrs = a.Encode();
    using RSA key = cert.GetRSAPrivateKey()!;
    byte[] value = key.SignData(attrs, HashAlgorithmName.SHA256,
        RSASignaturePadding.Pkcs1);
    attrs[0] = 0xA0; // signed as a SET, stored as [0]
 
    var w = new AsnWriter(AsnEncodingRules.DER);
    var tag0 = new Asn1Tag(TagClass.ContextSpecific, 0);
    using (w.PushSequence())
    {
        w.WriteObjectIdentifier("1.2.840.113549.1.7.2"); // signed data
        using (w.PushSequence(tag0))
        using (w.PushSequence())
        {
            w.WriteInteger(1);
            using (w.PushSetOf()) Algorithm(w, Sha256);
            using (w.PushSequence()) w.WriteObjectIdentifier(Data); // detached
            using (w.PushSetOf(tag0)) w.WriteEncodedValue(cert.RawData);
            using (w.PushSetOf())
            using (w.PushSequence()) // signer info
            {
                w.WriteInteger(1);
                using (w.PushSequence())
                {
                    w.WriteEncodedValue(cert.IssuerName.RawData);
                    w.WriteInteger(cert.SerialNumberBytes.Span);
                }
                Algorithm(w, Sha256);
                w.WriteEncodedValue(attrs);
                Algorithm(w, "1.2.840.113549.1.1.1", withNull: true); // RSA
                w.WriteOctetString(value);
            }
        }
    }
    return w.Encode();
}
 
static void Attribute(AsnWriter w, string oid, Action<AsnWriter> value)
{
    using (w.PushSequence())
    {
        w.WriteObjectIdentifier(oid);
        using (w.PushSetOf()) value(w);
    }
}
 
static void Algorithm(AsnWriter w, string oid, bool withNull = false)
{
    using (w.PushSequence())
    {
        w.WriteObjectIdentifier(oid);
        if (withNull) w.WriteNull();
    }
}
 
// A self-signed certificate for trying this out. Viewers won't trust it.
static X509Certificate2 TestCertificate(string subject)
{
    using RSA key = RSA.Create(3072);
    var request = new CertificateRequest(subject, key,
        HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
    request.CertificateExtensions.Add(new X509KeyUsageExtension(
        X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.NonRepudiation, true));
    var now = DateTimeOffset.UtcNow;
    return request.CreateSelfSigned(now.AddDays(-1), now.AddYears(1));
}

Step 1 adds a visible signature field on the W-9’s “Sign Here” line and saves the offsets:

Terminal
dotnet run -- prepare
Text
Hole for the CMS: bytes 141765 to 174535
Digest: 9E798D1115A8DB57BB7345B4F3BE53412EC689C8675EC64331D3C1C45B007284

Step 2 hashes the file around the hole, gets the CMS, fills it in and verifies the result:

Terminal
dotnet run -- complete
Text
Digest: 9E798D1115A8DB57BB7345B4F3BE53412EC689C8675EC64331D3C1C45B007284
CMS from the signer: 1623 bytes
Signature1: valid=True, chain=Trusted, coverage=WholeDocument
Signed bytes: 142885, hash matches the digest: True

Only the 32-byte digest goes to the signer, and only the CMS comes back. With a real service, replace SignDigest with its client call: send digest, receive the detached CMS. The stand-in writes the CMS with System.Formats.Asn1 because .NET’s SignedCms always hashes the content itself and can’t sign a digest it was handed. A service that builds the CMS does the same on its side.

fw9-signed.pdf carries a PAdES signature (ETSI.CAdES.detached) over the whole file except the hole, and page 1 shows “Digitally signed by Ada Lovelace” with the date. OpenSignedData() reads those signed bytes back from the file.

Check it with another tool

Poppler’s pdfsig reads the same file independently:

Terminal
pdfsig fw9-signed.pdf
Text
Digital Signature Info of: fw9-signed.pdf
Signature #1:
  - Signature Field Name: Signature1
  - Signer Certificate Common Name: Ada Lovelace
  - Signer full Distinguished Name: CN=Ada Lovelace
  - Signing Time: Oct 31 2026 20:41:01
  - Signing Hash Algorithm: SHA-256
  - Signature Type: ETSI.CAdES.detached
  - Signed Ranges: [0 - 141765], [174535 - 175655]
  - Total document signed
  - Signature Validation: Signature is Valid.
  - Certificate Validation: Certificate issuer is unknown.

The issuer is unknown only because the test certificate is self-signed.

Which way to sign

  • The signer returns a raw signature over a hash, as most HSMs, smart cards and cloud key services do: PdfSigningCredential.FromExternalSigner with doc.Sign is one call, and OnePDF builds the CMS. See Sign a PDF in C#.
  • The signer returns a whole CMS, the key is one Sign doesn’t take (Ed25519, Ed448 or post-quantum), or signing happens later or elsewhere: use the placeholder, as on this page.

Options

  • One process. Skip hole.txt: send p.Digest, then call p.Fill("fw9-signed.pdf", cms).
  • Hash. Hash = HashAlgorithmName.SHA384 in the options sets the algorithm of p.Digest; pass the same one to ComputeDigest.
  • Sub-filter. The default is ETSI.CAdES.detached (PAdES). Set SubFilter = "adbe.pkcs7.detached" for a signer that returns plain PKCS#7.
  • Room for the CMS. The hole holds 16,384 bytes of CMS by default. Pass contentsReserve: to AddSignaturePlaceholder for a long certificate chain or an embedded time-stamp (64 bytes to 4 MiB).
  • Invisible, certifying, field lock. The same options as Sign: leave out Rectangle and PageIndex for an invisible signature, set Certify or FieldLock as needed.

Limits

  • Not signed until filled. Between the steps the file has an empty signature; don’t hand it out.
  • One fill per hole. Fill checks the hole first. Wrong offsets or a hole already filled throw PdfSigningException (BadHole), and a CMS too large for the hole throws ContentsTooSmall with the size needed. Either way nothing is written. For a bigger hole, run step 1 again on the original file.
  • The CMS is yours. OnePDF doesn’t add a time-stamp or choose the padding here; your signer does.
  • Verification keys. OnePDF’s own verification checks RSA and ECDSA signatures. An Ed25519, Ed448 or post-quantum signature comes back as NotChecked, so check it with another tool.
  • No long-term validation data (PAdES B-LT and B-LTA) is written.

The hole’s exact layout and every check Fill makes are in Signatures.