sgcSign 2026.9.0 是一次重量级的发布。其中大部分内容源自客户的需求,集中在三个方面:知道你即将用哪一张证书签名、构建一个十年后验证器仍然接受的签名,以及用签名自身以外的东西去验证签名。
本文逐一介绍这些新功能,并给出每一项对应的 Delphi 代码。文末还有简短的一节,说明由早期版本生成、应当重新签署的那些签名。
可供选择的证书列表
过去枚举证书只会返回一组显示名称,这足以填满一个下拉框,却不足以让人做出判断。来自同一家颁发机构、签发给同一个人的两张卡,在那份列表里看起来完全一样。
现在枚举结果会带上 SHA-1 指纹、税务识别号、序列号、颁发者和有效期,而且对 Windows 证书存储、PKCS#11 令牌和 PFX 文件的处理方式完全一致。已过期的证书以及没有私钥的证书可以被过滤掉。指纹可以直接传给 SelectCertificateByThumbprint,这样用户选中的那张证书就是真正用于签名的那一张。
uses
sgcSign_KeyProvider_WinCertStore, sgcSign_X509, sgcSign_Types;
var
oProvider: TsgcWindowsCertStoreProvider;
oList: TsgcX509CertificateList;
i: Integer;
begin
oProvider := TsgcWindowsCertStoreProvider.Create(nil);
Try
// only certificates that are still valid and hold a private key
oList := oProvider.EnumerateCertificateList([cfNotExpired, cfPrivateKey]);
Try
for i := 0 to oList.Count - 1 do
Memo1.Lines.Add(Format('%s | %s | %s | %s .. %s | %s',
[oList[i].Subject, oList[i].NIF, oList[i].SerialNumber,
DateToStr(oList[i].NotBefore), DateToStr(oList[i].NotAfter),
oList[i].Thumbprint]));
// and sign with exactly the one that was chosen
oProvider.SelectCertificateByThumbprint(oList[0].Thumbprint);
Finally
oList.Free;
End;
Finally
oProvider.Free;
End;
end;
不带参数的调用保持不变,因此现有代码照常工作。
无需 PIN 即可清点多插槽卡片
一张合格签名卡往往存放着不止一张证书,每一张背后都有各自的 PIN。波兰的卡片是最常见的情形,比如带两个配置的 Certum 卡,或者带三个容器的 PWPW Sigillum 卡。仅仅为了给用户显示一份列表就向他们索要三次 PIN,并不是一种可行的界面设计。
现在完全不登录也可以清点一个 PKCS#11 令牌。TokenSlotCount 会报告实际插有令牌的插槽数量,也就是值得遍历的范围,而每一条记录都保存了它来自哪个插槽以及令牌标签,这样只有当某张证书真的被选中时,才需要索要对应的 PIN。
uses
sgcSign_KeyProvider_PKCS11, sgcSign_X509, sgcSign_Types;
var
oPKCS11: TsgcPKCS11Provider;
oList: TsgcX509CertificateList;
i: Integer;
begin
oPKCS11 := TsgcPKCS11Provider.Create(nil);
Try
oPKCS11.LibraryPath := 'C:\Windows\System32\cryptoCertum3PKCS.dll';
ShowMessage(Format('%d slots hold a token', [oPKCS11.TokenSlotCount]));
// walks every slot that holds a token, never logs in, never needs a PIN
oList := oPKCS11.EnumerateCertificateListAllSlots([cfNotExpired]);
Try
for i := 0 to oList.Count - 1 do
Memo1.Lines.Add(Format('slot %d (%s): %s',
[oList[i].SlotIndex, oList[i].TokenLabel, oList[i].Subject]));
Finally
oList.Free;
End;
Finally
oPKCS11.Free;
End;
end;
找到签发你证书的那一张证书
长期签名配置需要颁发者证书,而大多数合格签名卡只存放你自己的那一张。每个密钥提供程序现在都新增了两个调用来找到它:GetIssuerCertificate 返回签发你当前签名证书的那张证书,GetCertificateChain 返回它之上的整条路径。匹配是通过密码学方式校验的,而不是靠名称,因此一家更换过签名密钥的机构不会与它的前任混淆。
到哪里去找是一个需要决定的问题,所以它是一个属性。默认会搜索 Windows 证书存储,iluLocalStore 搜索随应用程序一起分发的 PEM 或 DER 文件,而 iluAIA 会从你自己证书中记录的地址下载证书,由于它需要访问网络,默认处于关闭状态。
uses
sgcSign_Classes, sgcSign_X509;
var
vIssuerDER: TBytes;
oChain: TsgcCertificateChain;
begin
oProvider.IssuerLookup := [iluSystemStore, iluLocalStore];
oProvider.IssuerFiles.Add('certs\ca-intermediate.pem');
oProvider.IssuerFiles.Add('certs\ca-root.pem');
vIssuerDER := oProvider.GetIssuerCertificate;
oChain := oProvider.GetCertificateChain;
end;
两个新的 PAdES 配置
spPAdESBasicT 在签名中嵌入时间戳而不包含吊销数据,当签名只需要证明它是什么时候生成的,这正是你想要的。spPAdESDocumentArchive 则走向另一端,在长期配置之上再加一个归档时间戳,覆盖包括吊销数据在内的整个文档,这样即使第一个时间戳自身的有效期已经过去,文件依然可以校验。
uses
sgcSign_PAdES, sgcSign_Types;
var
oPAdES: TsgcPAdESSigner;
begin
oPAdES := TsgcPAdESSigner.Create(nil);
Try
oPAdES.KeyProvider := oProvider;
oPAdES.Profile.Profile := spPAdESDocumentArchive;
oPAdES.TSAClient := TSAClient1;
oPAdES.OCSPClient := OCSPClient1;
// revocation lists you supply yourself, for an authority whose CRL is
// issued by its own root. IssuerCertificate is resolved automatically
// when it is left empty and OCSPClient is assigned
oPAdES.CRLFiles.Add('crl\ca-intermediate.crl');
oPAdES.SignPDFFile('contract.pdf', 'contract-signed.pdf');
Finally
oPAdES.Free;
End;
end;
证书会报告它携带的全部内容
主题和颁发者过去只报告解析器能识别的那七个属性,其余的一律丢弃。现在它们会报告证书中的每一个属性,通信地址会被解码成可读的行,并且任何属性都可以通过它的 OID 读取。
uses
sgcSign_X509;
var
i: Integer;
begin
// by OID: organizationIdentifier
ShowMessage(oCert.GetSubjectAttribute('2.5.4.97'));
// or the full list, in the order the certificate declares it
for i := 0 to oCert.SubjectAttributeCount - 1 do
Memo1.Lines.Add(oCert.SubjectAttributeOID[i] + ' = ' +
oCert.SubjectAttributeValue[i]);
end;
带签名的时间戳请求
有些合格时间戳机构,尤其是波兰的那几家,不会响应普通的 RFC 3161 请求。它们要求请求本身被包装进一个 CMS SignedData 并加以签名。现在这只是一个属性,不再需要你手工构建。
uses
sgcSign_TSA;
begin
oTSA.URL := 'https://tsa.example.com';
oTSA.RequestFormat := trfCMS; // plain RFC 3161 is still the default
oTSA.KeyProvider := oProvider; // trfCMS needs one
// authorities differ in what they expect inside the wrapper
oTSA.SignOptions.IncludeSignedAttributes := True;
// and the exact bytes are available when an authority needs checking
oTSA.OnBeforeSendRequest := DoBeforeSendRequest;
oTSA.OnAfterReceiveResponse := DoAfterReceiveResponse;
end;
默认的结构与 PWPW Sigillum 时间戳机构所接受的请求一致。现有代码仍然发送普通请求,除非你设置 RequestFormat,否则什么都不会改变。
Authenticode 交叉证书
内核模式驱动的签名必须通过一张交叉证书链接到 Microsoft Code Verification Root,这正是 signtool /ac 所嵌入的东西。sgcSign 现在也能以同样的方式嵌入额外的证书。
uses
sgcSign_Authenticode;
var
oSigner: TsgcAuthenticodeSigner;
begin
oSigner := TsgcAuthenticodeSigner.Create(nil);
Try
oSigner.KeyProvider := oProvider;
oSigner.AddCertificateFromFile('MSCV-VSClass3.cer');
// or from bytes you already hold
// oSigner.AddCertificate(vCrossCertDER);
oSigner.SignFile('driver.sys', 'driver-signed.sys');
Finally
oSigner.Free;
End;
end;
它们会进入每一个嵌套签名,而不添加任何证书时,签名与原先逐字节完全相同。签名服务器接受一个 add_certs 字段,命令行则提供可重复使用的 --add-cert 选项。
带信任锚的验证
这是本次发布中最重要的改动。在此之前,验证器会从它正在检查的文档里取出签名证书,然后确认那个密钥签署了那份文档。这只能证明写文档的人同时也写了里面的签名,仅此而已。任何人都能造出一份可以通过验证的文档。
现在可以给验证提供信任锚,验证过程会据此构建并校验证书链。信任锚通过 SHA-256 指纹匹配,或者通过用它自己的密钥验签来匹配,绝不靠名称。
uses
sgcSign_Verifier, sgcSign_Types;
var
oVerifier: TsgcSignatureVerifier;
begin
oVerifier := TsgcSignatureVerifier.Create(nil);
Try
oVerifier.TrustedCertificates.Add('certs\qualified-root.pem');
// or a Windows certificate store by name
oVerifier.TrustedCertificateStore := 'ROOT';
oVerifier.RequireTrustedChain := True;
oVerifier.CheckKeyUsage := True;
oVerifier.RequireCompleteRevocationCheck := True;
if oVerifier.Verify(vSignedXML) = vsValid then
ShowMessage('signed, and chained to a root you trust');
Finally
oVerifier.Free;
End;
end;
没有配置信任锚的验证器会返回与以前相同的结论,因此升级不会破坏任何东西。但有一点确实变了:对于从未链接到信任锚的签名,ETSI TS 119 102-2 报告不再给出 total-passed,而是给出 indeterminate 并附带 NO_CERTIFICATE_CHAIN_FOUND。由早期版本保存下来的报告需要重新生成。
统一的 HTTP 传输层,支持代理
这个库会在好几个地方发起网络请求:时间戳客户端、OCSP 和吊销列表客户端、欧盟信任列表下载,以及云密钥提供程序。它们各自对如何发起请求都有一套自己的想法。现在它们共用同一个传输层,由一个 HTTPOptions 属性控制。
uses
sgcSign_WinHTTP;
begin
oTSA.HTTPOptions.Proxy.Mode := pxCustom; // pxSystem is the default
oTSA.HTTPOptions.Proxy.URL := 'proxy.corp.local:8080';
oTSA.HTTPOptions.Proxy.Username := 'user';
oTSA.HTTPOptions.Proxy.Password := 'secret';
// the certificate presented when the gateway asks for client authentication
oTSA.HTTPOptions.ClientCertificate.StoreName := 'MY';
oTSA.HTTPOptions.ClientCertificate.Thumbprint := 'a1b2c3...';
oTSA.HTTPOptions.MinTLSVersion := tlsTLS1_2;
end;
代理可以是整机范围的那一个、完全不使用代理、一个明确的地址,或者通过 WPAD 或 PAC 脚本解析出来的按用户设置,也就是浏览器所采用的方式。每一项设置的默认值都与这些请求以前的行为一致。如果某个网关无法用这些设置来描述,新增的 OnHTTPRequest 事件可以整体取代传输层。
其他值得了解的细节
OCSP nonce。吊销请求现在会携带一个来自系统密码学生成器的随机 nonce,并据此检查响应。没有回显 nonce 的响应仍然被接受,因为 RFC 6960 允许预先生成的响应,但回显了不同 nonce 的响应会被拒绝。对于不接受该扩展的响应方,可以用 NonceEnabled 把它关掉。
欧盟信任列表 pivot 固定。新增的 RequirePinnedPivot 属性决定信任列表清单是否必须链接到某个已固定的官方公报 pivot 指纹,由 LOTLPivotPinned 和 LastPivotFingerprint 报告结果。这项检查原本就存在,却从来没有被任何地方调用过。随产品分发的固定常量目前仍然是文档中的占位值,因此在你填入真实指纹并打开该属性之前,都会报告未命中。
可以自己选择的摘要算法。CAdES 和 PKCS#11 都新增了 HashAlgorithm 属性,默认值为 SHA-256,因此现有代码生成的字节保持不变。CAdES 过去把 SHA-256 当作字面量写进每一个摘要算法字段,而 PKCS#11 则根据传入数据的长度来挑选它的 DigestInfo 头,所以其他摘要算法根本无法表达。现在一张卡可以按要求用 SHA-1、SHA-256、SHA-384 或 SHA-512 签名。
带签名回调的 ASiC。新增的 BuildCAdES 重载接受一个回调,而不是已经生成好的签名字节。它会先构建 META-INF/ASiCManifest.xml,把这些字节原样交给你的回调,再把返回的内容存为 META-INF/signature.p7s,这是唯一能让签名覆盖清单文件的顺序。对于不带清单的 ASiC-S,回调收到的是数据文档本身。如果你更愿意分成两个明确的步骤,GetCAdESSignedData 会返回同样的字节。
云 KMS 证书。AWS KMS 和 Google Cloud KMS 新增了 SetCertificate 和 SetCertificateFromFile,与 HashiCorp Vault 早已具备的这一对方法相对应。这两个服务都只交出一个裸公钥,而此前没有办法告诉它们哪一张 X.509 证书属于它。
由运行命令行的机器获取时间戳。sgcsign 命令行新增了 --tsa-direct,它会直接向时间戳机构请求,而不经过 sgcSign Server。请把它与 --tsa 一起使用。
签名服务器
服务器端也有属于自己的一份清单。现在一个 Authenticode 签名可以携带两个以上的嵌套签名,并且每一个使用不同的证书,方式是提供一个有序的 hash_algorithms 列表,例如 sha1,sha256,sha384,或者一个有序的 providers 列表,例如 certA:sha256,certB:sha1,两种方式最多都支持四个条目。这是为了发布一个同时由即将到期的证书和它的替换证书签名的文件。在任何签名开始之前,每一张证书都会先对照 API 密钥的权限进行检查。
Windows 目录文件现在可以签名了:上传端点接受 catalog 作为格式,并对 makecat 生成的那类现有 .cat 文件进行签名,这样驱动程序包的签名方式就和普通程序一样了。
新增的 /api/v1/sign/raw 端点会对你已经计算好的摘要签名,并且只返回签名值,没有 PKCS#7 封装,没有签名属性,也没有时间戳。这正是 signtool 通过它的 /dlib 回调所要求的。由于它会对交给它的任何摘要签名,默认处于关闭状态,需要用 allow_raw_sign 逐个提供程序地开启。
API 密钥以及创建它们的用户现在按项目隔离,项目管理员管理自己项目中的密钥,密钥可以启用和禁用,而不再只能单向吊销。每个密钥的速率限制和每日配额在密钥创建之后仍可修改。新增的 SessionAbsoluteMaxMin 设置把管理员会话的总生命周期默认限制在十二小时,因为过去每一次通过认证的请求都会把过期时间无上限地往后推。审计日志可以按客户端地址过滤,控制台和 CSV 导出中都支持,部分地址会从左侧开始匹配。另外新增的转发标头设置默认关闭,当服务器运行在反向代理之后时可以还原真实的客户端地址,并且只有当连接来自列表中受信任的代理时才会采信。
应当重新签署的签名
早期版本中的三个缺陷会生成结构上错误的文件,而升级并不能修复已经写出的文件。如果下面任何一条符合你签署过的内容,请用 2026.9.0 重新签署一次。
- 任何用 EC 密钥签署的内容。用 EC 密钥生成的每一个 PAdES、CAdES、Authenticode、NuGet 和 RFC 3161 签名都是无效的。标准要求 CMS 结构内部的签名值必须是一个 DER 编码的 ECDSA-Sig-Value,而密钥提供程序产生的是 r 和 s 的原始拼接,中间没有任何转换。现在 P-256、P-384 和 P-521 都能正常工作。XAdES 和 XML-DSig 这两条路径原本就是正确的,因此有意保持原样。
- 每一个 CAdES 签名,以及任何用卡片、USB 令牌或云密钥签署的内容。
SignData没有书面的约定,各个提供程序对它的参数究竟是数据本身还是数据的摘要存在分歧,结果一个 CAdES 签名就成了对哈希的哈希做 RSA 运算。现在这个参数就是原始字节,由提供程序用它所配置的摘要算法去做哈希。 - 在另一个签名器用过同一个密钥提供程序之后签署的 PAdES 文件。
Profile.HashAlgorithm只在构造函数里写入一次,之后再也没有被读取过,因此在 Facturae 或 SAF-T 签名器之后签署的 PDF,可能在一个声明 SHA-256 的签名里实际是对 SHA-1 摘要做的 RSA 运算。
验证方面也朝同样的方向做了改动。Authenticode 验证从来没有真正检查过签名,它只是重新计算文件哈希并与签名里的那个做比较,因此伪造一个被 sgcSign 判定为有效签名的文件根本不需要私钥。吊销响应和时间戳令牌在嵌入时没有经过验证。欧盟信任列表在下载和使用时完全没有做任何验证。所有这些现在都会执行其名称所暗示的检查,每一项的完整说明都在变更日志中。
获取方式
sgcSign 2026.9.0 现已发布,提供完整源代码和一年的更新,支持 Delphi 7 到 Delphi 13 Florence、对应的 C++ Builder 版本,以及 .NET。
有疑问或反馈?联系我们,回复你的正是编写这些代码的人。
