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.
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:
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:
dotnet run -- prepareHole 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:
dotnet run -- completeDigest: 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:
pdfsig fw9-signed.pdfDigital 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.FromExternalSignerwithdoc.Signis one call, and OnePDF builds the CMS. See Sign a PDF in C#. - The signer returns a whole CMS, the key is one
Signdoesn’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: sendp.Digest, then callp.Fill("fw9-signed.pdf", cms). - Hash.
Hash = HashAlgorithmName.SHA384in the options sets the algorithm ofp.Digest; pass the same one toComputeDigest. - Sub-filter. The default is
ETSI.CAdES.detached(PAdES). SetSubFilter = "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:toAddSignaturePlaceholderfor a long certificate chain or an embedded time-stamp (64 bytes to 4 MiB). - Invisible, certifying, field lock. The same options as
Sign: leave outRectangleandPageIndexfor an invisible signature, setCertifyorFieldLockas needed.
Limits
- Not signed until filled. Between the steps the file has an empty signature; don’t hand it out.
- One fill per hole.
Fillchecks the hole first. Wrong offsets or a hole already filled throwPdfSigningException(BadHole), and a CMS too large for the hole throwsContentsTooSmallwith 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.