技术文章

Delphi 里用 SM2、SM3、SM4 做 PDF 签名与加密

PDFlibPas(面向 Delphi 与 C++Builder 的 PDF 库)用纯 Object Pascal 实现了中国商用密码套件:SM3 散列(GB/T 32905)、SM4 分组加密(GB/T 32907)和 SM2 椭圆曲线签名(GB/T 32918)。凭这些,它经 SetSignProcessSM2Identity 用 SM2-with-SM3 的 CMS 签名签署 PDF,经 strength 6 的 Encrypt 用 SM4 crypt filter 加密文档,全程不碰系统提供程序或外部 DLL

想用它的常见理由是采购要求,不是好奇心。卖进中国政务、金融和医疗的系统越来越多地被要求用国密算法,而 Windows CryptoAPI 和 CNG 既不能用 SM2 签名也不能用 SM3 散列。签名路径若必须走平台,答案就是不行。纯 Pascal 实现在 Win32、Win64 和 FPC 目标上给出同样的答案,而且是在文档管线底下没有会漂移的原生依赖的情况下给出的

为什么用纯 Object Pascal 造 GB/T 套件?

PDFlibPas 用 Pascal 造 SM2、SM3 和 SM4,因为它面向的平台提供程序没有一个能干这活。CryptoAPI 和 CNG 没有 SM3 签名支持,所以在 SM2 identity 出现之前,库在摘要设为 SM3 时刻意拒绝签名:另一个选项是 SignerInfo 声明一个摘要 OID 却对别的东西签了名——那是无声的错误签名,而不是看得见的错误

三个算法也有自然的构建顺序,库就是照着来的。SM3 在前,因为 SM2 的 ZA identity 散列和消息摘要都需要它。SM4 随后,作为独立单元。SM2 最后,先是对照规范附录里的公开示例曲线验证过的裸原语,然后作为签名 identity 接进已经处理 Ed25519、Ed448 和 ML-DSA 的同一个 CMS 构建器。纯 Pascal 的 Ed448 与 Brainpool ECDSA 曲线和 FIPS 204 ML-DSA 后量子签名器两文描述了同一思路,SM2 作为又一种密钥类型接进这个框架

这些原语是公开函数,PDF 签名之外也能用。PDFlibDigest 里的 SM3String 和 SM3ByteArray 返回原始 32 字节摘要(一字节对一字符,不是十六进制),PDFlibSM4 导出两种口味的 ECB 与 CBC 辅助函数:按块对齐、长度不对就返回空串的版本,以及 SM4EncryptCBCPKCS7 这类面向任意长度数据的 PKCS#7 版本

uses
  SysUtils, PDFlibDigest, PDFlibSM4;

function RawToHex(const S: AnsiString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + IntToHex(Byte(S[I]), 2);
end;

var
  Digest, Key, IV, Cipher, Plain: AnsiString;
begin
  // GB/T 32905 示例:SM3("abc")
  Digest := SM3String('abc');
  Assert(LowerCase(RawToHex(Digest)) =
    '66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0');

  // SM4 需要 16 字节密钥;CBC 需要 16 字节 IV
  Key := 'SixteenByteKey!!';
  IV  := 'InitVector16byte';
  Cipher := SM4EncryptCBCPKCS7(Key, IV, 'Invoice 2026-0042 approved');
  Plain  := SM4DecryptCBCPKCS7(Key, IV, Cipher);
  Assert(Plain = 'Invoice 2026-0042 approved');
end;

拿手去数那 16 字节字符串就知道了。SM4 测试套件里本该是 16 字节的两个测试串实际是 17 字节,而按块对齐的辅助函数按设计在长度不对时返回空串,于是故障以一条费解的断言浮现,而不是异常

用 SM2 identity 签署 PDF

SetSignProcessSM2Identity 或 SetSignProcessSM2IdentityFromFile 把证书和私钥挂上签名进程后,PDFlibPas 就用 SM2 密钥。证书是 DER,私钥可以是原始 32 字节标量、未加密的 PKCS#8 容器或 SEC 1 的 ECPrivateKey。挂上 SM2 identity 会清掉同一进程上的 Ed25519、Ed448、ML-DSA 或 Brainpool identity,并关掉证书库与 CNG 签名,所以一个签名进程绝不会同时带着两把竞争的密钥

var
  Pdf: TPDFlib;
  SignId: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    SignId := Pdf.NewSignProcessFromFile('contract.pdf', '');
    if SignId = 0 then
      raise Exception.Create('cannot open source PDF');
    Pdf.SetSignProcessField(SignId, 'Signature1');
    if Pdf.SetSignProcessSM2IdentityFromFile(SignId,
         'signer-sm2.cer', 'signer-sm2.key') <> 1 then
      raise Exception.Create('SM2 certificate or key file missing');
    Pdf.SetSignProcessInfo(SignId, 'Approved', 'Shanghai', '[email protected]');
    Pdf.SetSignProcessCustomSubFilter(SignId, 'ETSI.CAdES.detached');
    Pdf.SetSignProcessDigestAlgorithm(SignId, 8);   // 8 = SM3
    Pdf.EndSignProcessToFile(SignId, 'contract-signed.pdf');
    if Pdf.GetSignProcessResult(SignId) <> 1 then
      raise Exception.CreateFmt('SM2 signing failed, code %d',
        [Pdf.GetSignProcessResult(SignId)]);
    Pdf.ReleaseSignProcess(SignId);
  finally
    Pdf.Free;
  end;
end;

注意 SetSignProcessSM2IdentityFromFile 的返回值说明了什么、没说明什么。返回 1 表示两个文件都被读取并存到了签名进程上。真正的检查——从私钥标量导出公钥并与证书比对——在应用签名时运行,所以密钥对不匹配表现为非 1 的 GetSignProcessResult,而不是 identity 调用报错

SetSignProcessDigestAlgorithm 的代码 8 选 SM3,配上 SM2 identity 后它实际是强制的:CMS 构建器对 SM2 密钥一律强制 SM3,不管进程请求的是什么,正如 Ed25519 密钥被强制到 SHA-512。反过来也不行。设为 SM3 却没挂 SM2 identity 的签名进程,在 adbe.pkcs7.detached 或 ETSI.CAdES.detached 下不签名直接退出,因为库里没有别的签名器能产出与声明 OID 匹配的 SM3 签名

SM2-with-SM3 的 SignerInfo 里装了什么?

PDFlibPas 的 SM2 签名对 DER 编码的签名属性计算——标准 CMS 构造——但 SM2 比 ECDSA 多一步。GB/T 32918.2 把消息连同 ZA 一起散列,ZA 是定义为 ZA = SM3(ENTL || ID || a || b || Gx || Gy || xA || yA) 的 identity 摘要,其中每个曲线部分都是 32 字节大端值,ID 是签名者的区分标识符。随后签名按 r = (e + x1) mod n、s = ((1 + d)-1(k - r·d)) mod n 计算,nonce k 取自库的 CSPRNG,退化轮次重试

PDFlibPas 在签名和验证两侧都用规范里的默认区分标识符——16 字符字符串 1234567812345678。没有 API 可以改它。这与绝大多数已部署的 SM2 证书一致,但如果对手方用自定义 ID 签名,ZA 值不同,签名就无法验证——尽管其他每个字节都正确

(r, s) 对以两个 INTEGER 的 DER SEQUENCE 进入 CMS,即所有 DSA 家族 SignerInfo 用的布局。PDFlibPas 写的是 GM/T 0006 注册的算法标识符:1.2.156.10197.1.301 是 SM2 密钥,1.2.156.10197.1.401 是 SM3,1.2.156.10197.1.501 是 SM2-with-SM3。用 SM3 创建的 RFC 3161 时间戳请求(SetTimeStampOptions digest 8)带同样的 SM3 OID。3.589.1 之前的版本写的是未注册的 1.2.156.101.1 弧;验证仍然接受它,所以那些构建签出的文档在 PDFlibPas 里继续通过验证,但第三方验证器不认识它们——凡是离开你自己管线的东西都值得重签,这是条硬理由

PDFlibPas 在 Delphi 里构造 SM2-with-SM3 SignerInfo:摘要覆盖 ZA,即 ENTL、ID 与曲线参数的 SM3,默认 ID 为 1234567812345678,r 与 s 按 GB/T 32918.2 以新鲜 CSPRNG nonce 计算,CMS 携带 GM/T 0006 OID 1.2.156.10197.1.301、1.2.156.10197.1.401 和 1.2.156.10197.1.501
签名值是对签名属性做 DER 编码、含两个 INTEGER 的 SEQUENCE;3.589.1 之前的构建写了第三方验证器拒绝的未注册 OID 弧,离开管线的文件都应重签

SM2 验证坏在哪:DER 符号字节与丢失的进位

SM2 代码的大部分工程精力花在了两个产出貌似合理结果的缺陷上。两者都是手写大整数密码学的典型病,知道了就都能便宜地避开

第一个是 DER 符号字节。ASN.1 的 INTEGER 是有符号的,所以编码器对任何最高位置位的 r 或 s 正确地前置 0x00,得到 33 字节的值。验证器把每个整数转成固定 32 字节宽度时没有先剥掉那个字节。所有 r 和 s 里约一半最高位置位,所以新签的文档有很大比例验证失败。第一次探针运行碰巧抽到短值、通过了;用随机 nonce 签名的 Delphi Win32 套件立刻暴露了问题。验证器现在对任何 33 字节整数先删前导零再补到 32 字节,更长的直接拒绝

PDFlibPas 在 Delphi 里的 SM2 签名验证缺陷与修复:r 或 s 最高位置位时 ASN.1 INTEGER 编码前置 0x00,旧验证器不剥符号字节就把 33 字节的值补到固定 32 字节,修正后的验证器删除前导零并拒绝更长的值
所有 r 与 s 值里约一半带 0x00 前缀,貌似合理的输出成批验证失败;短探针值让首跑通过,直到 Delphi Win32 套件用随机 nonce 签名

第二个是 256 位加法里丢失的进位。域元素是八个小端 UInt32 肢,模加写成"先加,结果不低于 p 再减 p"。对已约减的输入,和低于 2p 但仍可能超过 2256,一旦超过,丢掉的进位把值折回低 256 位,对 p 的比较于是宣称一切正常。用大坐标倍点会算错;小倍数没事。可见症状是 1G 到 8G 与参考一致而 9G、10G、11G 不一致,修复靠把 1280 组加法输入输出对照独立计算的值、再钻进第一对分歧找出来

PDFlibSM2 里修正后的加法保留原始肢加法返回的进位,当进位置位或结果不低于 p 时减一次 p;减一次就够,因为 p 大于 2255。这个模式适用于任何略高于 2 的幂的素数域。如果你的加法辅助函数丢掉最后的进位,就把倍数测到远超倍点首次跨过字边界的地方,别只测 1G、2G、3G

SM3 为什么错两次才对

第一版 SM3 实现没过一个标准测试向量,与它并肩写的独立参考实现也没过。两个互相一致、却与标准相悖的实现,指向的是对规范的共同误解,不是抄写错误。把公式逐行对照公开的 SM3 文本,找到三处凭记忆写代码的地方

  • 消息扩展把 7 位循环移位用在 W[j-13] 上;W[j-6] 是普通的 XOR 项
  • SS1 用模 232 加法组合 rotl(A, 12)、E 和轮转后的轮常量,不是 XOR
  • 16 到 63 轮的 FF 是多数函数 (X AND Y) OR (X AND Z) OR (Y AND Z);SHA 风格的选择函数属于 GG

三处改完,空串、abc 和 64 字节双块向量全部对上。SM4 随后一次通过,因为 S 盒和测试向量是在写任何代码之前就从规范文本抄的:轮密钥 rk0 = F12186F9、单块向量 681EDF34D206965E86B3E94F536E4246、百万次迭代向量 595298C7C6FD271F0402F804C33D3F66。SM2 的工作加了一条相关规则:长十六进制常量靠分组生成并做长度校验,绝不手敲——一次手抄的基点坐标少了一位,让一轮调试去追根本不存在的实现 bug

用 SM4 crypt filter 加密 PDF

TPDFlib.Encrypt 用 strength 6 经 ISO 32000-1 §7.6.5 的 crypt filter 框架以 SM4-128 加密选中的文档。加密字典用标准 security handler,带 /V 4、/R 4 和 /Length 128,/StdCF crypt filter 携带 /CFM /SM4。以 strength 6 保存会把文件抬到至少 PDF 1.6,GetEncryptionLevel 对这种文件报 7,公钥加密经 strength 4 的 EncryptPublicKey 拿到同一选项

var
  Pdf: TPDFlib;
  Perm: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('report.pdf', '') <> 1 then
      raise Exception.Create('cannot load PDF');
    // 允许打印、复制和填表;不允许编辑或装配
    Perm := Pdf.EncodePermissions(1, 1, 0, 0, 1, 1, 0, 1);
    if Pdf.Encrypt('owner-secret', 'reader-pass', 6, Perm) <> 1 then
      raise Exception.Create('SM4 encryption failed');
    Pdf.SaveToFile('report-sm4.pdf');
  finally
    Pdf.Free;
  end;
end;

每个字符串和流都按 16 字节随机 IV 加 SM4-CBC 密文加 PKCS#7 填充加密,与 AESV2 用的布局相同。逐对象密钥遵循 ISO 32000-1 §7.6.2 的 Algorithm 1:文件密钥、对象号低三位字节、生成号两字节,再加 sAlT 后缀,用 MD5 散列并截到 16 字节。密码到密钥的推导是 revision 4 那套,所以 strength 6 继承它的限制:密码不做 SASLprep 处理(只有 AES-256 的 strength 3、4、5 做),密钥推导也不比 AESV2 的更强

PDFlibPas 在 Delphi 里 Encrypt strength 6 写出的 SM4 crypt filter 结构:/V 4、/R 4、/Length 128,/StdCF 滤镜设为 /CFM /SM4,逐对象 MD5 密钥由文件密钥、对象号与生成号加 sAlT 组成并截到 16 字节,每个流存为 16 字节 IV 加带 PKCS#7 填充的 SM4-CBC 密文
字符串与流用 AESV2 字节布局、SM4 内核,但 /SM4 不在 ISO 32000 定义的 /CFM 值之列,主流阅读器无法解密,面向公众分发应留在 AES-256

部署前该知道的互操作边界

老实的总结是:SM2 签名在上面说的 OID 和默认 ID 边界内可互操作,SM4 加密只与认同一套约定的软件互操作。/SM4 不是 ISO 32000-1 表 25 或 ISO 32000-2 定义的 /CFM 值之一——那两处定义了 None、V2、AESV2 和 AESV3。主流 PDF 阅读器遇到 /CFM /SM4 不会解密文件。PDFlibPas 自己的 security handler 认 /SM4 和变体 /SM4V1,所以 PDFlibPas 又写又读的封闭管线没问题;面向公众的文件应该用 AES-256

签名方面,边界是证书布局和对面的验证器。签名进程背后的检查 PLValidateSM2Identity 接受 GM/T 0015 布局——SubjectPublicKeyInfo 携带 id-ecPublicKey、以 SM2 曲线 1.2.156.10197.1.301 为参数——也接受把 SM2 OID 放在算法字段本身的证书,并拒绝导出公点与私钥标量不匹配的一切。在生产里依赖 SM2 签名之前,用你实际要用的 CA 的证书签一份文档,再用对手方运行的验证器验一遍。开签名进程之前可以先自己跑 identity 检查:

uses
  SysUtils, PDFlibModernSign;

var
  CertDER, KeyDER, Scalar: AnsiString;
begin
  // CertDER 和 KeyDER 从磁盘或 HSM 导出加载
  // Out 参数单独放一个变量:把 KeyDER 同时当
  // Const 输入和 Out 输出传进去,输入会先被清空
  if not PLValidateSM2Identity(CertDER, KeyDER, Scalar) then
    raise Exception.Create('certificate and SM2 private key do not match');
  FillChar(Pointer(Scalar)^, Length(Scalar), 0);
end;

那条注释记录的是集成工作里的真实缺陷。同一个 AnsiString 变量既当 Const 输入又当 Out 输出时,编译器在进入例程时对 Out 变量做了 finalize,输入在例程读到它之前就空了,identity 检查在一把完全正常的密钥上失败。任何以 Const 收密钥、以 Out 返回派生密钥的 API,调用侧都得用两个变量

FAQ

PDFlibPas 做 SM2、SM3、SM4 需要 Windows CNG 或外部 DLL 吗?

不需要。PDFlibPas 在 PDFlibDigest、PDFlibSM4 和 PDFlibSM2 里用 Object Pascal 实现了全部三个算法,SM2 签名由库的 CMS 代码构建和验证。产出 SM2 签名的过程中 Windows CryptoAPI 和 CNG 都不参与

SM2 能配 SHA-256 代替 SM3 吗?

不能。签名进程挂着 SM2 identity 时,PDFlibPas 的 CMS 构建器一律用 SM3,因为 GB/T 32918.2 定义 SM2 签名对包含 ZA identity 散列的 SM3 摘要计算。在 SM2 签名进程上把摘要设为 SHA-256 不会改变结果

Adobe Acrobat 能打开 PDFlibPas 用 SM4 加密的 PDF 吗?

别指望能。/CFM /SM4 crypt filter 在 ISO 32000 定义的值之外,主流阅读器不认识它。SM4 加密留给用 PDFlibPas 或兼容实现读文件的封闭系统,面向公众分发的文档用 AES-256

SM2 签名、SM3 散列、SM4 加密和全套 PAdES 签名 API 都随 PDFlibPas,Delphi PDF Library 发布,在 Windows 与 Unix 目标上可用 Delphi、C++Builder 和 FPC/Lazarus 编译