技術文章

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 加密文件,全程不涉及任何 OS provider 或外部 DLL

想要這套東西的常見原因是採購要求,不是好奇心。賣進中國政府、金融與醫療體系的系統,越來越被要求使用國家演算法,而 Windows CryptoAPI 與 CNG 既不會用 SM2 簽章、也不會用 SM3 雜湊。如果簽章路徑走平台,答案就是直接不行。純 Pascal 實作在 Win32、Win64 與 FPC 目標上給出同樣的答案,而且不會在文件管線底下墊一個會動的原生依賴

為什麼用純 Object Pascal 打造 GB/T 套件?

PDFlibPas 用 Pascal 實作 SM2、SM3 與 SM4,是因為它已經針對的任何平台 provider 都做不了這件事。CryptoAPI 與 CNG 沒有 SM3 簽章支援,所以在 SM2 身分出現之前,程式庫在摘要設成 SM3 時刻意拒絕簽署:替代方案是一個 SignerInfo 宣告某個摘要 OID、實際卻對別的東西簽章,那是靜默的錯誤簽章,而不是看得見的錯誤

三個演算法有天然的建置順序,程式庫也是照著走的。SM3 先來,因為 SM2 的 ZA 身分雜湊與訊息摘要都需要它。SM4 第二,作為獨立單元。SM2 最後,先是對照規範附錄公布的範例曲線驗證過的原始原語,然後接進已經處理 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 身分簽署 PDF

當 SetSignProcessSM2Identity 或 SetSignProcessSM2IdentityFromFile 把憑證與私鑰掛上簽署流程,這個流程就用 SM2 金鑰。憑證是 DER,私鑰可以是原始 32 位元組純量、未加密的 PKCS#8 容器或 SEC 1 的 ECPrivateKey。掛上 SM2 身分會清掉同一流程上的 Ed25519、Ed448、ML-DSA 或 Brainpool 身分,並關閉憑證儲存區與 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 現身,不會出現在身分呼叫上

SetSignProcessDigestAlgorithm 的代碼 8 選 SM3,配上 SM2 身分時實質上是強制的:CMS 建構器對 SM2 金鑰一律強制 SM3,不管流程要求什麼,就像 Ed25519 金鑰一律被強制到 SHA-512。反向組合也被擋下。設成 SM3 卻沒有 SM2 身分的簽署流程,在 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) 的身分摘要,其中曲線各部分都是 32 位元組 big-endian 值,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 註冊的演算法識別碼:SM2 金鑰為 1.2.156.10197.1.301,SM3 為 1.2.156.10197.1.401,SM2-with-SM3 為 1.2.156.10197.1.501。用 SM3 建立的 RFC 3161 時間戳請求(SetTimeStampOptions 摘要 8)帶同樣的 SM3 OID。3.589.1 之前的版本寫的是未註冊的 1.2.156.101.1 弧;驗證端仍接受它,所以用那些版本簽的文件在 PDFlibPas 裡照樣驗得過,但第三方驗證器不認得,這正是離開自家管線的文件都值得重簽的好理由

PDFlibPas for Delphi 的 SM2-with-SM3 SignerInfo 建構:摘要涵蓋 ZA = SM3(ENTL、ID 與曲線參數),預設 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
簽章值是對已簽屬性的兩個 INTEGER 組成之 DER 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 for Delphi 的 SM2 簽章驗證缺陷與修復:r 或 s 最高位元被設立時 ASN.1 INTEGER 編碼會補 0x00,舊驗證器把 33 位元組值直接補齊到固定 32 位元組、未剝簽章位元組,修正後的驗證器刪除前導零並拒絕更長的值
所有 r 與 s 值約有一半帶 0x00 前綴,看似合理的輸出因此大量驗證失敗;短的探測值讓第一次試跑過關,直到 Delphi Win32 套件用隨機 nonce 簽章

第二個是 256 位元加法裡被丟掉的進位。域元素是八個 little-endian 的 UInt32 limb,模加寫成「先相加,結果不低於 p 就再減 p」。對已約化的輸入,和不超過 2p 但仍可能超過 2256,一旦超過,被丟掉的進位把值摺回低 256 位元,那裡與 p 的比較說一切正常。大座標的點加倍算錯;小倍數沒事。看得見的症狀是 1G 到 8G 與參考值相符,9G、10G 與 11G 卻不對,修復靠的是拿 1280 組加法輸入輸出對照獨立計算的值、再鑽進第一個分歧對

PDFlibSM2 裡修正後的加法保留原始 limb 加法回傳的進位,當進位被設立或結果不低於 p 時減一次 p;減一次就夠,因為 p 大於 2255。這個模式適用於任何剛好高過 2 的冪的質數域。如果您的加法輔助丟棄了最後的進位,請測到點加倍第一次跨過字組邊界之後很遠的倍數,別只測 1G、2G、3G

SM3 為什麼錯了兩次才對

第一版 SM3 實作全滅所有標準測試向量,與它並行寫的一份獨立參考實作也一樣。兩個彼此相符、卻都與標準相左的實作,指向的是對規範的共同誤讀,而不是謄寫錯誤。把公式逐行對照公布的 SM3 文本,找到三處憑記憶寫代碼的地方

  • 訊息擴充要對 W[j-13] 做 7 位元旋轉;W[j-6] 是純 XOR 項
  • SS1 把 rotl(A, 12)、E 與旋轉後的輪常數用模 232 加法結合,不是 XOR
  • 第 16 到 63 輪,FF 是多數函數 (X AND Y) OR (X AND Z) OR (Y AND Z);SHA 風格的選擇函數屬於 GG

三處修正之後,空字串、abc 與 64 位元組雙區塊向量全部對上。SM4 接著一次到位,因為 S-box 與測試向量在任何代碼動筆之前就從規範文本抄來:輪金鑰 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 加上帶 PKCS#7 填充的 SM4-CBC 密文,與 AESV2 用的版面相同。逐物件金鑰遵循 ISO 32000-1 §7.6.2 的演算法 1:檔案金鑰、物件編號的三個低位元組與生成號的兩個位元組,再加上 sAlT 後綴,以 MD5 雜湊並截斷到 16 位元組。密碼到金鑰的推導用的是 revision 4 那套,所以 strength 6 繼承了它的限制:密碼不做 SASLprep 處理(只有 AES-256 的 strength 3、4、5 會),金鑰推導也不會比 AESV2 更強

Delphi 中 PDFlibPas Encrypt 以 strength 6 寫出的 SM4 crypt filter 結構:/V 4、/R 4 與 /Length 128,/StdCF filter 設為 /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 簽發的憑證簽一份文件,再拿對手方執行的驗證器驗一遍。開始簽署流程前,您可以自己先跑身分檢查:

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 輸出時,編譯器在進入常式時 finalize 了 Out 變數,輸入在常式讀它之前就空了,身分檢查在一把完全沒問題的金鑰上失敗。任何以 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 身分時,PDFlibPas 的 CMS 建構器一律用 SM3,因為 GB/T 32918.2 定義的 SM2 簽章是對包含 ZA 身分雜湊的 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 編譯