Overview
This guide shows .NET developers how to sign a PDF with an eSigner document signing or eSeal certificate entirely from C#: get an OAuth token, read the certificate chain, hash the PDF, authorize and call signHash, wrap the raw signature in a PKCS#7 (CMS) container with BouncyCastle, and embed it in the PDF. The document never leaves your server; only a 32-byte SHA-256 hash is sent to eSigner.
Who it is for: developers integrating eSigner for Documents into .NET 6+ applications, especially teams that already have the Java or cURL examples working and need the C# equivalent.
Key takeaways
- Send the hash as Base64 of the raw 32 digest bytes (44 characters), never as a 64-character hex string. Hex causes invalid length of hash.
- signAlgo must be an OID string: 1.2.840.113549.1.1.11 for RSA-SHA256 or 1.2.840.10045.4.3.2 for ECDSA-SHA256. Names like SHA256withRSA are rejected.
- Call credentials/authorize first and pass the returned SAD to signHash, with the same hash in both calls. Skipping it causes SAD parameter not supplied.
- eSeal certificates need “clientData”: “DS_ESEAL” on credentials/list and no OTP. IV or IV+OV document signing certificates use DS (default) and need an OTP.
- signHash returns a raw signature, not a PKCS#7. You must build the CMS SignedData yourself (Step 5) before a PDF reader will accept it.
- What you hash for signHash is the DER-encoded signed attributes of the CMS, not the PDF byte range directly. The byte-range digest goes inside those attributes as messageDigest.
All examples use the sandbox (oauth-sandbox.ssl.com, cs-try.ssl.com). To go live, use login.ssl.com and cs.ssl.com with production credentials.
eSigner PDF signing flow · 6 steps across your app and eSigner
Hashing, PKCS#7 assembly and embedding run in your code; eSigner only issues the token, returns the certificates, and signs the hash.
Prerequisites
You need a .NET 6 or later project, three NuGet packages, a validated sandbox document signing certificate enrolled in eSigner, and an OAuth client.
|
Item |
Value |
Notes |
|
Runtime |
.NET 6, 7 or 8 |
The sample targets net8.0. System.Text.Json and System.Net.Http.Json are in-box. |
|
PDF library |
itext7 8.0.5 |
Reserves the signature field and embeds the PKCS#7. AGPL or commercial license. DynamicPDF and PDFsharp alternatives are covered in Step 6. |
|
BouncyCastle adapter |
itext7.bouncy-castle-adapter 8.0.5 |
Pulls in BouncyCastle.Cryptography 2.x, the official .NET BouncyCastle. Prefer it over the unofficial BouncyCastle.NetCore port; the namespaces are the same. |
|
Certificate |
Sandbox document signing order, validated and enrolled in eSigner |
See Integration Guide: Testing Remote Signing with eSigner CSC API. For unattended signing, use an OV certificate converted to eSeal (Remove 2FA). |
|
OAuth client |
Client ID and Client Secret |
Install the packages
dotnet new console -n ESignerPdfSigning -f net8.0 cd ESignerPdfSigning dotnet add package itext7 --version 8.0.5 dotnet add package itext7.bouncy-castle-adapter --version 8.0.5
Endpoints
|
Purpose |
Sandbox |
Production |
|
OAuth token |
https://oauth-sandbox.ssl.com/oauth2/token |
https://login.ssl.com/oauth2/token |
|
CSC API base |
https://cs-try.ssl.com/csc/v0/ |
https://cs.ssl.com/csc/v0/ |
Sandbox and production use different accounts, credentials and certificates. A sandbox token sent to cs.ssl.com (or the reverse) fails with 401.
Get an access token (C#)
The token is valid for one hour. This mirrors the cURL password-grant request in Remote Document Signing with eSigner CSC API.
var tokenBody = new {
client_id = clientId, client_secret = clientSecret,
grant_type = "password", username = username, password = password };
using var resp = await http.PostAsJsonAsync(
"https://oauth-sandbox.ssl.com/oauth2/token", tokenBody, Json);
resp.EnsureSuccessStatusCode();
string accessToken = (await resp.Content.ReadFromJsonAsync<JsonNode>())!["access_token"]!.GetValue<string>();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
Important: Json is new JsonSerializerOptions(). Do not rely on the PostAsJsonAsync default, which applies camelCase and silently renames SAD to sad and OTP to otp. The CSC API field names are case-sensitive (see Common errors).
Step 1 — Retrieve the credential ID and certificate chain
Call credentials/list to get the credential ID, then credentials/info to get the signer certificate and its chain as Base64 DER strings. You need the chain in Step 5 to build the PKCS#7.
clientData must match the certificate type. An empty or wrong value returns an empty credentialIDs array, which then surfaces later as a confusing error.
|
Certificate |
clientData |
OTP at authorize |
|
eSeal (OV document signing with 2FA removed) |
DS_ESEAL |
Not required |
|
IV or IV+OV document signing |
DS (default) |
Required |
// credentials/list
JsonNode list = await PostAsync($"{cscBase}/csc/v0/credentials/list",
new { clientData = "DS_ESEAL" });
string credentialId = list["credentialIDs"]![0]!.GetValue<string>();
// credentials/info — ask for the full chain
JsonNode info = await PostAsync($"{cscBase}/csc/v0/credentials/info",
new { credentialID = credentialId, certificates = "chain", certInfo = true, authInfo = true });
var parser = new Org.BouncyCastle.X509.X509CertificateParser();
Org.BouncyCastle.X509.X509Certificate[] chain = info["cert"]!["certificates"]!.AsArray()
.Select(n => parser.ReadCertificate(Convert.FromBase64String(n!.GetValue<string>())))
.ToArray(); // chain[0] is the signer certificate
Building X509Certificate2 objects instead. If other parts of your application use the .NET certificate types, decode each Base64 string the same way:
using System.Security.Cryptography.X509Certificates; byte[] der = Convert.FromBase64String(certB64); var cert = new X509Certificate2(der); // .NET 6–8 // var cert = X509CertificateLoader.LoadCertificate(der); // .NET 9+ // Convert between the two types when needed: var bcCert = new Org.BouncyCastle.X509.X509CertificateParser().ReadCertificate(cert.RawData);
The strings are Base64 DER, not PEM. Do not add —–BEGIN CERTIFICATE—– headers, and do not pass them to X509Certificate2.CreateFromPem.
Pick signAlgo from the key type. Check the signer certificate’s public key: RSA keys use 1.2.840.113549.1.1.11, EC keys use 1.2.840.10045.4.3.2.
bool isEc = chain[0].GetPublicKey() is Org.BouncyCastle.Crypto.Parameters.ECPublicKeyParameters; string signAlgo = isEc ? "1.2.840.10045.4.3.2" : "1.2.840.113549.1.1.11";
Step 2 — Compute the PDF hash and the hash to sign
The value you send to eSigner is Base64(SHA-256(DER(signedAttributes))), where the signed attributes contain the SHA-256 of the PDF byte range. That takes three sub-steps.
2a. Reserve the signature field and hash the byte range. A PDF signature covers the whole file except the /Contents placeholder that will hold the PKCS#7. iText writes a prepared copy with that placeholder and streams the covered bytes (the /ByteRange) to your container, so you never compute offsets by hand. Hashing the whole original file is a common mistake: the digest will not match and the signature shows as invalid.
class DigestCapturingContainer : IExternalSignatureContainer
{
public byte[]? Digest { get; private set; }
public byte[] Sign(Stream data)
{
Digest = SHA256.HashData(data); // SHA-256 of the /ByteRange
return Array.Empty<byte>(); // placeholder stays empty for now
}
public void ModifySigningDictionary(PdfDictionary dict)
{
dict.Put(PdfName.Filter, PdfName.Adobe_PPKLite);
dict.Put(PdfName.SubFilter, PdfName.Adbe_pkcs7_detached);
}
}
var capture = new DigestCapturingContainer();
using (var reader = new PdfReader("input.pdf"))
using (var prepared = File.Create("signed.pdf.prepared.tmp"))
{
var signer = new PdfSigner(reader, prepared, new StampingProperties());
signer.SetFieldName("eSignerSignature");
signer.SignExternalContainer(capture, 16384); // reserve 16 KB for the PKCS#7
}
byte[] pdfDigest = capture.Digest!;
Do not change the prepared file after this point. Any byte change outside /Contents invalidates the digest.
2b. Build the CMS signed attributes. PDF readers expect the signature to cover a small set of attributes, one of which is the PDF digest. Build them now, because their DER encoding is what eSigner signs.
static byte[] BuildSignedAttributes(byte[] pdfDigest, X509Certificate signerCert)
{
var attrs = new Asn1EncodableVector();
attrs.Add(new CmsAttribute(CmsAttributes.ContentType, new DerSet(CmsObjectIdentifiers.Data)));
attrs.Add(new CmsAttribute(CmsAttributes.MessageDigest, new DerSet(new DerOctetString(pdfDigest))));
attrs.Add(new CmsAttribute(PkcsOids.IdAASigningCertificateV2,
new DerSet(new SigningCertificateV2(new EssCertIDv2(SHA256.HashData(signerCert.GetEncoded()))))));
return new DerSet(attrs).GetEncoded(Asn1Encodable.Der);
}
byte[] signedAttrs = BuildSignedAttributes(pdfDigest, chain[0]);
2c. Hash and Base64-encode. This is the most common error point.
byte[] hashToSign = SHA256.HashData(signedAttrs); // 32 raw bytes
string hashB64 = Convert.ToBase64String(hashToSign); // 44 characters, ends with "="
// WRONG: 64 hex characters ? "invalid length of hash"
// string hashB64 = Convert.ToHexString(hashToSign);
// string hashB64 = BitConverter.ToString(hashToSign).Replace("-", "");
A quick self-check: a correct SHA-256 value is exactly 44 characters long. If yours is 64, it is hex.
Step 3 — Obtain the SAD
credentials/authorize returns the Signature Activation Data (SAD), a short-lived token that authorizes signing the specific hash you send. Send the Base64 hash from Step 2c as a one-element array.
var authBody = new Dictionary<string, object>
{
["credentialID"] = credentialId,
["numSignatures"] = 1,
["hash"] = new[] { hashB64 }
};
if (!string.IsNullOrEmpty(otp))
authBody["OTP"] = otp; // DS (IV / IV+OV) certificates only; eSeal needs no OTP
JsonNode auth = await PostAsync($"{cscBase}/csc/v0/credentials/authorize", authBody);
string sad = auth["SAD"]!.GetValue<string>();
Rules that prevent most authorize failures:
- Get a fresh OTP immediately before this call. Each OTP is valid for 30 seconds.
- Call authorize once per hash, right before signHash. Do not cache the SAD across documents.
- If the hash in signHash differs from the hash you authorized, signing fails. Compute it once and reuse the same string.
Step 4 — Call signHash
signatures/signHash returns the raw signature over your hash. Send credentialID, SAD, the same hash array, and signAlgo as an OID string.
var signBody = new
{
credentialID = credentialId,
SAD = sad, // exact casing; see Common errors
hash = new[] { hashB64 },
signAlgo = signAlgo // "1.2.840.113549.1.1.11" or "1.2.840.10045.4.3.2"
};
JsonNode signed = await PostAsync($"{cscBase}/csc/v0/signatures/signHash", signBody);
byte[] rawSignature = Convert.FromBase64String(signed["signatures"]![0]!.GetValue<string>());
|
signAlgo value |
Algorithm |
Use when |
|
1.2.840.113549.1.1.11 |
sha256WithRSAEncryption |
Signer certificate has an RSA key (most document signing certificates) |
|
1.2.840.10045.4.3.2 |
ecdsa-with-SHA256 |
Signer certificate has an EC key |
Values such as SHA256withRSA, RS256, RSA or SHA256 return invalid signAlgo parameter.
rawSignature is a bare RSA (or ECDSA) signature, typically 256 bytes for an RSA 2048 key or 384 bytes for RSA 3072. It is not a PKCS#7. Writing it straight into the PDF produces a signature that every reader rejects as corrupt.
Step 5 — Build the PKCS#7 (CMS SignedData) with BouncyCastle
Wrap the raw signature, the signed attributes from Step 2b, and the certificate chain from Step 1 into a detached CMS SignedData, then DER-encode it. The signed attributes must be the exact bytes you hashed in Step 2c, or the signature will not verify.
static byte[] BuildPkcs7(byte[] signedAttrsDer, byte[] rawSignature, X509Certificate[] chain, bool isEc)
{
X509Certificate signer = chain[0];
var sha256 = new AlgorithmIdentifier(NistObjectIdentifiers.IdSha256, DerNull.Instance);
var sigAlg = isEc
? new AlgorithmIdentifier(X9ObjectIdentifiers.ECDsaWithSha256)
: new AlgorithmIdentifier(PkcsOids.RsaEncryption, DerNull.Instance);
if (isEc && rawSignature.Length == 64) // raw r||s ? DER SEQUENCE { r, s }
rawSignature = RawEcdsaToDer(rawSignature);
var signerInfo = new SignerInfo(
new SignerIdentifier(new IssuerAndSerialNumber(signer.IssuerDN, signer.SerialNumber)),
sha256,
(Asn1Set)Asn1Object.FromByteArray(signedAttrsDer), // same bytes you hashed
sigAlg,
new DerOctetString(rawSignature),
null);
var certs = new Asn1EncodableVector();
foreach (var c in chain) certs.Add(c.CertificateStructure);
var signedData = new SignedData(
new DerSet(sha256),
new ContentInfo(CmsObjectIdentifiers.Data, null), // detached: PDF bytes are not embedded
new DerSet(certs),
null,
new DerSet(signerInfo));
return new ContentInfo(CmsObjectIdentifiers.SignedData, signedData).GetEncoded(Asn1Encodable.Der);
}
static byte[] RawEcdsaToDer(byte[] raw)
{
int half = raw.Length / 2;
var r = new BigInteger(1, raw, 0, half);
var s = new BigInteger(1, raw, half, half);
return new DerSequence(new DerInteger(r), new DerInteger(s)).GetEncoded(Asn1Encodable.Der);
}
Why build the structure by hand instead of using CmsSignedDataGenerator? The generator expects to call a signer synchronously while it encodes. Building the SignerInfo directly lets you make the async eSigner calls between Step 2 and Step 5, which fits ASP.NET and background-job code.
The result is typically 4–7 KB with a three-certificate chain, well within the 16 KB reserved in Step 2a.
Step 6 — Embed the PKCS#7 into the PDF and verify
Open the prepared file from Step 2a and write the PKCS#7 into the reserved /Contents placeholder. iText’s deferred signing does this without touching any other byte.
class FixedContainer : IExternalSignatureContainer
{
private readonly byte[] _pkcs7;
public FixedContainer(byte[] pkcs7) => _pkcs7 = pkcs7;
public byte[] Sign(Stream data) => _pkcs7;
public void ModifySigningDictionary(PdfDictionary dict) { }
}
using (var reader = new PdfReader("signed.pdf.prepared.tmp"))
using (var document = new PdfDocument(reader))
using (var output = File.Create("signed.pdf"))
{
PdfSigner.SignDeferred(document, "eSignerSignature", output, new FixedContainer(pkcs7));
}
File.Delete("signed.pdf.prepared.tmp");
Verify the result
- Open signed.pdf in Adobe Acrobat or Acrobat Reader and open the Signature Panel. The signature should show the document as unmodified since signing.
- Sandbox certificates chain to SSL.com’s development root, which Acrobat does not trust by default. Expect “signer’s identity is unknown” until you trust that root manually; the integrity check is what confirms your code works. Production certificates validate without this step.
- For an automated check, pdfsig signed.pdf (Poppler) or pyhanko sign validate signed.pdf reports whether the digest and signature verify.
Using DynamicPDF, PDFsharp or another library. Steps 1–5 do not change. The PDF library must support external (deferred) signing: reserve a /Contents placeholder, give you the /ByteRange bytes or their digest, and accept a precomputed PKCS#7 without re-saving the file. Check your library’s documentation for its external-signer API, and confirm /SubFilter is adbe.pkcs7.detached. A DynamicPDF-specific sample is not included yet.
Optional next steps
- Add an RFC 3161 timestamp from http://ts.ssl.com as an unsigned attribute so the signature stays valid after the certificate expires.
- Add revocation data for long-term validation (LTV). The eSigner Document Signing Gateway does both if you prefer a packaged service.
Full C# sample
This single Program.cs runs all six steps against the sandbox. Set the environment variables listed in the header, put an unsigned input.pdf next to the project, and run dotnet run — input.pdf signed.pdf. For an IV or IV+OV certificate, set ESIGNER_CLIENT_DATA=DS and a fresh ESIGNER_OTP immediately before running.
Project file (ESignerPdfSigning.csproj)
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="itext7" Version="8.0.5" />
<PackageReference Include="itext7.bouncy-castle-adapter" Version="8.0.5" />
</ItemGroup>
</Project>
Program.cs
// eSigner end-to-end PDF signing in C#/.NET (sandbox)
//
// Flow: OAuth token -> credentials/list -> credentials/info -> PDF /ByteRange SHA-256
// -> CMS signed attributes -> credentials/authorize (SAD) -> signatures/signHash
// -> BouncyCastle PKCS#7 (CMS SignedData) -> embed in PDF (iText 8)
//
// NuGet: itext7 8.0.5, itext7.bouncy-castle-adapter 8.0.5 (brings BouncyCastle.Cryptography 2.x)
// Run: dotnet run -- input.pdf signed.pdf
// Env: ESIGNER_CLIENT_ID, ESIGNER_CLIENT_SECRET, ESIGNER_USERNAME, ESIGNER_PASSWORD
// ESIGNER_CLIENT_DATA DS_ESEAL (default, eSeal, no OTP) or DS (IV / IV+OV, OTP required)
// ESIGNER_OTP 6-digit OTP, DS certificates only
// ESIGNER_PRODUCTION set to "true" to use production endpoints
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
using iText.Kernel.Pdf;
using iText.Signatures;
using Org.BouncyCastle.Asn1;
using Org.BouncyCastle.Asn1.Cms;
using Org.BouncyCastle.Asn1.Ess;
using Org.BouncyCastle.Asn1.Nist;
using Org.BouncyCastle.Asn1.X509;
using Org.BouncyCastle.Asn1.X9;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Math;
using Org.BouncyCastle.X509;
using CmsAttribute = Org.BouncyCastle.Asn1.Cms.Attribute;
using PkcsOids = Org.BouncyCastle.Asn1.Pkcs.PkcsObjectIdentifiers;
using X509Certificate = Org.BouncyCastle.X509.X509Certificate;
const string FieldName = "eSignerSignature";
const int ReservedSignatureBytes = 16384;
string inputPdf = args.Length > 0 ? args[0] : "input.pdf";
string outputPdf = args.Length > 1 ? args[1] : "signed.pdf";
string preparedPdf = outputPdf + ".prepared.tmp";
var cfg = ESignerConfig.FromEnvironment();
using var http = new HttpClient();
var api = new ESignerClient(http, cfg);
// OAuth: access token (valid 1 hour)
await api.GetAccessTokenAsync();
// Step 1: credential ID and certificate chain
string credentialId = await api.GetCredentialIdAsync();
X509Certificate[] chain = await api.GetCertificateChainAsync(credentialId);
X509Certificate signerCert = chain[0];
bool isEc = signerCert.GetPublicKey() is ECPublicKeyParameters;
string signAlgo = isEc ? "1.2.840.10045.4.3.2" : "1.2.840.113549.1.1.11";
Console.WriteLine($"Signer: {signerCert.SubjectDN} (signAlgo {signAlgo})");
// Step 2: reserve the signature field, hash the /ByteRange, build signed attributes, hash them
byte[] pdfDigest = PdfPreparer.PrepareAndHash(inputPdf, preparedPdf, FieldName, ReservedSignatureBytes);
byte[] signedAttrs = CmsBuilder.BuildSignedAttributes(pdfDigest, signerCert);
byte[] hashToSign = SHA256.HashData(signedAttrs);
string hashB64 = Convert.ToBase64String(hashToSign); // 44 chars. NOT Convert.ToHexString()
Console.WriteLine($"Hash to sign (Base64): {hashB64}");
// Step 3: SAD
string sad = await api.AuthorizeAsync(credentialId, hashB64);
// Step 4: raw signature
byte[] rawSignature = await api.SignHashAsync(credentialId, sad, hashB64, signAlgo);
// Step 5: PKCS#7 / CMS SignedData
byte[] pkcs7 = CmsBuilder.BuildPkcs7(signedAttrs, rawSignature, chain, isEc);
// Step 6: embed and clean up
PdfPreparer.EmbedSignature(preparedPdf, outputPdf, FieldName, pkcs7);
File.Delete(preparedPdf);
Console.WriteLine($"Signed PDF written to {outputPdf}");
// ---------------------------------------------------------------------------
record ESignerConfig(string OAuthTokenUrl, string CscBaseUrl, string ClientId, string ClientSecret,
string Username, string Password, string ClientData, string? Otp)
{
public static ESignerConfig FromEnvironment()
{
bool production = Environment.GetEnvironmentVariable("ESIGNER_PRODUCTION") == "true";
return new ESignerConfig(
production ? "https://login.ssl.com/oauth2/token" : "https://oauth-sandbox.ssl.com/oauth2/token",
production ? "https://cs.ssl.com" : "https://cs-try.ssl.com",
Require("ESIGNER_CLIENT_ID"),
Require("ESIGNER_CLIENT_SECRET"),
Require("ESIGNER_USERNAME"),
Require("ESIGNER_PASSWORD"),
Environment.GetEnvironmentVariable("ESIGNER_CLIENT_DATA") ?? "DS_ESEAL",
Environment.GetEnvironmentVariable("ESIGNER_OTP"));
}
static string Require(string name) =>
Environment.GetEnvironmentVariable(name) ?? throw new InvalidOperationException($"Set the {name} environment variable.");
}
class ESignerClient
{
// No naming policy: CSC field names (SAD, OTP, credentialID) are case-sensitive.
static readonly JsonSerializerOptions Json = new();
readonly HttpClient _http;
readonly ESignerConfig _cfg;
public ESignerClient(HttpClient http, ESignerConfig cfg) { _http = http; _cfg = cfg; }
public async Task GetAccessTokenAsync()
{
var body = new
{
client_id = _cfg.ClientId,
client_secret = _cfg.ClientSecret,
grant_type = "password",
username = _cfg.Username,
password = _cfg.Password
};
JsonNode res = await PostAsync(_cfg.OAuthTokenUrl, body);
string token = res["access_token"]!.GetValue<string>();
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
public async Task<string> GetCredentialIdAsync()
{
JsonNode res = await PostAsync($"{_cfg.CscBaseUrl}/csc/v0/credentials/list", new { clientData = _cfg.ClientData });
JsonArray ids = res["credentialIDs"]!.AsArray();
if (ids.Count == 0)
throw new InvalidOperationException(
$"No credentials returned for clientData={_cfg.ClientData}. Use DS_ESEAL for eSeal certificates, DS for IV/OV document signing.");
return ids[0]!.GetValue<string>();
}
public async Task<X509Certificate[]> GetCertificateChainAsync(string credentialId)
{
JsonNode res = await PostAsync($"{_cfg.CscBaseUrl}/csc/v0/credentials/info",
new { credentialID = credentialId, certificates = "chain", certInfo = true, authInfo = true });
var parser = new X509CertificateParser();
return res["cert"]!["certificates"]!.AsArray()
.Select(n => parser.ReadCertificate(Convert.FromBase64String(n!.GetValue<string>())))
.ToArray();
}
public async Task<string> AuthorizeAsync(string credentialId, string hashB64)
{
var body = new Dictionary<string, object>
{
["credentialID"] = credentialId,
["numSignatures"] = 1,
["hash"] = new[] { hashB64 }
};
if (!string.IsNullOrEmpty(_cfg.Otp))
body["OTP"] = _cfg.Otp; // DS certificates only; eSeal needs no OTP
JsonNode res = await PostAsync($"{_cfg.CscBaseUrl}/csc/v0/credentials/authorize", body);
return res["SAD"]!.GetValue<string>();
}
public async Task<byte[]> SignHashAsync(string credentialId, string sad, string hashB64, string signAlgo)
{
var body = new { credentialID = credentialId, SAD = sad, hash = new[] { hashB64 }, signAlgo };
JsonNode res = await PostAsync($"{_cfg.CscBaseUrl}/csc/v0/signatures/signHash", body);
return Convert.FromBase64String(res["signatures"]![0]!.GetValue<string>());
}
async Task<JsonNode> PostAsync(string url, object body)
{
using HttpResponseMessage resp = await _http.PostAsJsonAsync(url, body, Json);
string text = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new HttpRequestException($"POST {url} returned {(int)resp.StatusCode}: {text}");
return JsonNode.Parse(text)!;
}
}
static class PdfPreparer
{
public static byte[] PrepareAndHash(string inputPdf, string preparedPdf, string fieldName, int reservedBytes)
{
var capture = new DigestCapturingContainer();
using (var reader = new PdfReader(inputPdf))
using (var output = File.Create(preparedPdf))
{
var signer = new PdfSigner(reader, output, new StampingProperties());
signer.SetFieldName(fieldName);
signer.SignExternalContainer(capture, reservedBytes);
}
return capture.Digest ?? throw new InvalidOperationException("PDF digest was not computed.");
}
public static void EmbedSignature(string preparedPdf, string outputPdf, string fieldName, byte[] pkcs7)
{
using var reader = new PdfReader(preparedPdf);
using var document = new PdfDocument(reader);
using var output = File.Create(outputPdf);
PdfSigner.SignDeferred(document, fieldName, output, new FixedContainer(pkcs7));
}
}
class DigestCapturingContainer : IExternalSignatureContainer
{
public byte[]? Digest { get; private set; }
public byte[] Sign(Stream data)
{
Digest = SHA256.HashData(data); // SHA-256 of the PDF /ByteRange
return Array.Empty<byte>();
}
public void ModifySigningDictionary(PdfDictionary dict)
{
dict.Put(PdfName.Filter, PdfName.Adobe_PPKLite);
dict.Put(PdfName.SubFilter, PdfName.Adbe_pkcs7_detached);
}
}
class FixedContainer : IExternalSignatureContainer
{
private readonly byte[] _pkcs7;
public FixedContainer(byte[] pkcs7) => _pkcs7 = pkcs7;
public byte[] Sign(Stream data) => _pkcs7;
public void ModifySigningDictionary(PdfDictionary dict) { }
}
static class CmsBuilder
{
public static byte[] BuildSignedAttributes(byte[] pdfDigest, X509Certificate signerCert)
{
var attrs = new Asn1EncodableVector();
attrs.Add(new CmsAttribute(CmsAttributes.ContentType, new DerSet(CmsObjectIdentifiers.Data)));
attrs.Add(new CmsAttribute(CmsAttributes.MessageDigest, new DerSet(new DerOctetString(pdfDigest))));
attrs.Add(new CmsAttribute(PkcsOids.IdAASigningCertificateV2,
new DerSet(new SigningCertificateV2(new EssCertIDv2(SHA256.HashData(signerCert.GetEncoded()))))));
return new DerSet(attrs).GetEncoded(Asn1Encodable.Der);
}
public static byte[] BuildPkcs7(byte[] signedAttrsDer, byte[] rawSignature, X509Certificate[] chain, bool isEc)
{
X509Certificate signer = chain[0];
var sha256 = new AlgorithmIdentifier(NistObjectIdentifiers.IdSha256, DerNull.Instance);
var sigAlg = isEc
? new AlgorithmIdentifier(X9ObjectIdentifiers.ECDsaWithSha256)
: new AlgorithmIdentifier(PkcsOids.RsaEncryption, DerNull.Instance);
if (isEc && rawSignature.Length == 2 * EcCoordinateLength(signer))
// raw r||s -> DER SEQUENCE { r, s }
rawSignature = RawEcdsaToDer(rawSignature);
var signerInfo = new SignerInfo(
new SignerIdentifier(new IssuerAndSerialNumber(signer.IssuerDN, signer.SerialNumber)),
sha256,
(Asn1Set)Asn1Object.FromByteArray(signedAttrsDer),
sigAlg,
new DerOctetString(rawSignature),
null);
var certs = new Asn1EncodableVector();
foreach (X509Certificate c in chain) certs.Add(c.CertificateStructure);
var signedData = new SignedData(
new DerSet(sha256),
new ContentInfo(CmsObjectIdentifiers.Data, null),
new DerSet(certs),
null,
new DerSet(signerInfo));
return new ContentInfo(CmsObjectIdentifiers.SignedData, signedData).GetEncoded(Asn1Encodable.Der);
}
static int EcCoordinateLength(X509Certificate cert) => (((ECPublicKeyParameters)cert.GetPublicKey()).Parameters.Curve.FieldSize + 7) / 8;
static byte[] RawEcdsaToDer(byte[] raw)
{
int half = raw.Length / 2;
var r = new BigInteger(1, raw, 0, half);
var s = new BigInteger(1, raw, half, half);
return new DerSequence(new DerInteger(r), new DerInteger(s)).GetEncoded(Asn1Encodable.Der);
}
}
Common errors and fixes
Each row below is an error .NET developers have reported to support while building this flow. CSC errors return as {“error”: “invalid_request”, “error_description”: “…”}; read error_description, not just the HTTP status.
|
Error or symptom |
Step |
Cause |
Fix |
|
invalid length of hash |
3, 4 |
Hash sent as a 64-character hex string |
Use Convert.ToBase64String(hashBytes). A SHA-256 value is 44 Base64 characters. |
|
invalid signAlgo parameter |
4 |
Algorithm name instead of OID, or an OID that does not match the key |
Send 1.2.840.113549.1.1.11 for RSA or 1.2.840.10045.4.3.2 for EC, chosen from the signer certificate’s key. |
|
SAD parameter not supplied |
4 |
credentials/authorize was skipped, or the field was serialized as sad |
Call authorize first. Serialize with new JsonSerializerOptions() so SAD keeps its casing; PostAsJsonAsync without options applies camelCase. |
|
Empty credentialIDs array |
1 |
clientData does not match the certificate type |
DS_ESEAL for eSeal, DS (or omit) for IV / IV+OV |
|
OTP rejected at authorize |
3 |
OTP expired (30-second window), device clock drift, or OTP sent for an eSeal |
Pull a fresh OTP right before the call; sync the device clock; omit OTP for eSeal. |
|
401 partway through a working flow |
Any |
Access token expired after one hour, or sandbox token used on production URL |
Request a new token; match oauth-sandbox with cs-try and login with cs. |
|
An unexpected error occurred on signHash |
4 |
Generic server-side response; has been reported on the sandbox before |
Retry once. If it repeats, send support the full response body, UTC timestamp, credential ID and environment. |
|
Acrobat: “signature is invalid” or “document altered” |
2, 6 |
Hashed the whole file instead of the /ByteRange, or modified the prepared file before embedding |
Hash only what iText streams to the container; embed into the unmodified prepared file. |
|
Acrobat: signature corrupt or unreadable |
5, 6 |
Raw signHash bytes written to the PDF instead of a PKCS#7 |
Build the CMS SignedData in Step 5 and embed that. |
|
Acrobat: “signer’s identity is unknown” (sandbox) |
6 |
Sandbox chain ends at a development root Acrobat does not trust |
Expected in sandbox. Trust the root manually to test; production certificates validate normally. |
|
iText: “Not enough space” |
6 |
PKCS#7 larger than the reserved /Contents |
Increase the reserved size passed to SignExternalContainer (for example 32768). |
|
Message digest mismatch in a validator |
2, 5 |
Signed attributes rebuilt between hashing and embedding |
Build them once, hash those exact bytes, and pass the same bytes to Step 5. |
FAQ
Why do I hash the signed attributes and not the PDF directly? PDF readers check a CMS signature with signed attributes: the signature covers the attributes, and the attributes carry the PDF digest as messageDigest. If you sign the PDF digest directly, the structure no longer matches what readers verify.
Does the PDF leave my server? No. Only the 32-byte hash goes to eSigner. Use the /v1/pdf/upload and /v1/pdf/sign endpoints only if you want eSigner to handle the PDF for you.
Can I sign without an OTP? Yes, with an eSeal certificate. Convert an OV document signing certificate by choosing Remove 2FA on the order, then use clientData: DS_ESEAL. IV certificates cannot be converted.
Can I sign several PDFs in one call? The API accepts several hashes per authorize and signHash call (numSignatures and the hash array). Prepare each PDF, collect the hashes, authorize once with the matching count, then build one PKCS#7 per signature returned. Signatures come back in the same order as the hashes.
Is BouncyCastle.NetCore required? No. BouncyCastle.Cryptography is the official package and the one iText’s adapter uses. The code is the same for either.
Do I have to use iText? No. Steps 1–5 are library-neutral. Any PDF library that supports deferred signing with a precomputed PKCS#7 works; see Step 6.
Is there a Java or command-line version? Yes. See Remote Document Signing with eSigner CSC API for cURL and eSigner DocSignTool Command Guide for the Java command-line tool.