电子签名平台的开发难点并不在于画一个手写笔迹,而在于如何让一份电子文档具备与纸质合同同等的法律效力。C#生态下有完整的密码学API和PDF处理库,但把它们组合起来时,证书私钥保护、签名位置控制、时间戳嵌入以及验签逻辑都存在不少细节。本文以一个实际项目为例,拆解基于C#的电子签名平台从技术选型到落地验证的过程。

一、电子签名平台的技术架构与选型
我们开发的平台采用经典的B/S架构,前端负责PDF预览和签署界面渲染,后端使用ASP.NET Core提供API。这里有一个关键决策:签名数据不能在前端生成,因为前端无法安全保管私钥。即使使用UKey或移动端证书,浏览器环境也缺乏统一的密码学接口。因此我们把签名动作放在服务器端,由持有机构证书的后端服务对文档摘要进行加密签名。
在PDF处理库的选择上,我们对比了iTextSharp、PdfSharp、Aspose.Pdf和Spire.Pdf。PdfSharp虽然轻量,但对数字签名的支持较弱;Aspose.Pdf和Spire.Pdf功能强大但属于商业授权;iTextSharp则提供了完整的PdfStamper、PdfSignatureAppearance和AcroFields体系,而且4.x版本在LGPL/AGPL许可下可以满足内部系统需求。最终我们选择iTextSharp 5.5.x作为签章核心库。
架构上还需要一个时间戳服务模块。单纯用私钥签名只能证明签署主体,无法证明签署时间。可信时间戳通过连接国家授时中心或第三方CA机构的时间戳服务器,对签名后的哈希再次签名,从而固定时间点。这个模块需要做超时重试和异步记录。
二、C#中数字证书管理与签名实现
数字证书是电子签名的身份基础。在.NET中,X509Certificate2类既可以从Windows证书存储区读取,也可以从pfx文件加载。生产环境建议使用证书存储区配合私钥标记为不可导出,避免文件泄露。但为了部署方便,很多内部平台仍采用pfx文件加密码保护的方式。
X509Certificate2 cert = new X509Certificate2(@"C:Certsign.pfx", "123456", X509KeyStorageFlags.Exportable);
using (RSA rsa = cert.GetRSAPrivateKey())
{
byte[] data = System.Text.Encoding.UTF8.GetBytes("待签名数据");
byte[] signature = rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
bool valid = rsa.VerifyData(data, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
Console.WriteLine(valid);
}
上面的代码演示了用证书私钥对数据进行RSA签名和验证。实际签署PDF时,签名对象不是普通字符串,而是文档的哈希值。iTextSharp会在内部调用我们传入的IExternalSignature实现来完成真正的加密操作,因此我们需要封装一个类把X509Certificate2的私钥暴露给签章流程。
证书链校验同样重要。很多自签名证书在测试时可以通过,但对接真实CA证书后会出现链不完整的情况。可以使用X509Chain类构建完整的证书链,并检查ChainStatus中是否存在NotTimeValid或UntrustedRoot。只有链校验通过的证书才允许进入签署队列。
三、PDF可见签名与验签的核心代码
PDF签名分为不可见签名和可见签名。不可见签名只把签名值写入PDF的签名字典,不改变页面外观;可见签名则需要在指定页面区域嵌入签名图片和文字信息。大多数合同场景都要求可见签名,例如在合同末尾盖上公司印章。
using iTextSharp.text.pdf;
using iTextSharp.text;
using System.IO;
public void SignPdf(string src, string dest, X509Certificate2 cert)
{
PdfReader reader = new PdfReader(src);
using (FileStream fs = new FileStream(dest, FileMode.Create))
{
PdfStamper stamper = PdfStamper.CreateSignature(reader, fs, ' ');
PdfSignatureAppearance appearance = stamper.SignatureAppearance;
appearance.Reason = "合同签署";
appearance.Location = "电子签名平台";
appearance.SetVisibleSignature(new iTextSharp.text.Rectangle(50, 50, 250, 150), reader.NumberOfPages, "sign1");
appearance.SignatureGraphic = Image.GetInstance(@"C:Certseal.png");
appearance.CertificationLevel = PdfSignatureAppearance.NOT_CERTIFIED;
IExternalSignature pks = new X509Certificate2Signature(cert, "SHA-256");
MakeSignature.SignDetached(appearance, pks, new[] { cert }, null, null, null, 0, CryptoStandard.CMS);
}
}
上述代码会读取源PDF,在最后一页的指定矩形区域添加签名外观,并调用MakeSignature.SignDetached生成CMS格式的签名数据。需要注意的是,签名图片路径使用了本地文件,实际部署时应从对象存储或数据库读取字节数组,避免文件系统权限问题。
验签时需要遍历PDF中的所有签名域,并调用AcroFields的VerifySignature方法。这个方法要求传入签名名称,返回一个PdfPKCS7对象,从中可以获取签署证书、签名时间以及签名是否完整。我们会在验签服务中同时检查证书是否被吊销、时间戳是否可信,只有全部通过才返回签署有效。
public bool VerifyPdf(string path)
{
PdfReader reader = new PdfReader(path);
AcroFields fields = reader.AcroFields;
foreach (string name in fields.GetSignatureNames())
{
PdfPKCS7 pk = fields.VerifySignature(name);
bool signatureValid = pk.Verify();
X509Certificate2 signCert = pk.SigningCertificate;
// 进一步检查证书链和吊销状态
return signatureValid;
}
return false;
}
这里省略了证书链吊销检查的实现,实际项目需要调用OCSP或CRL服务。还有一个常见问题:如果PDF经过多次编辑或追加,验签可能只对最后一次签名有效,因此平台需要限制文档一旦签署后不可再修改。
四、性能优化与踩坑记录
并发签署是本项目遇到的最棘手问题。PdfStamper内部使用了非线程安全的全局状态,多个线程同时签署不同PDF文件时,偶尔会出现签名后文件无法打开。解决办法有两个:一是给签名操作加全局锁,简单但吞吐量低;二是为每个线程创建独立的PdfReader和PdfStamper实例,并确保iTextSharp版本中没有静态缓存冲突。我们最终采用信号量限制并发签署数量,同时对大文件进行分片上传和异步处理。
中文显示异常也排了很长时间。默认的Helvetica字体不支持中文,签章图片外的文字和表单域内容会变成乱码。解决方式是注册中文字体,例如使用iTextSharp自带的STSong-Light和UniGB-UCS2-H编码。如果使用自定义字体文件,需要确保字体嵌入到PDF中,否则阅读器会尝试本地匹配,造成排版差异。
另一个值得注意的安全问题是私钥保护。不要将pfx密码硬编码在配置文件中,建议使用DPAPI加密配置节,或者接入Azure Key Vault、HashiCorp Vault等密钥管理服务。私钥一旦泄露,攻击者可以伪造任意合同,法律风险极高。
最后是合规性。根据电子签名法的要求,可靠的电子签名需要满足专有性、控制性、防篡改和防抵赖四个条件。我们在签名时采用SHA-256摘要算法、RSA 2048位以上密钥、可信时间戳和证书链验证,就是为了在司法举证时能够形成完整的证据链。