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 プロバイダも外部 DLL も介しません
これが欲しくなる理由は、好奇心ではなく調達要件であるのが普通です。中国の政府・金融・医療に納入されるシステムには、国産アルゴリズムの使用がますます求められており、Windows CryptoAPI と CNG は SM2 での署名も SM3 でのハッシュもできません。署名経路がプラットフォームを通るなら、答えは単純にノーです。純 Pascal 実装なら Win32、Win64、FPC ターゲットのどれでも同じ答えが出て、文書パイプラインの下でネイティブ依存がうろつくこともありません
GB/T スイートを純正 Object Pascal で作る理由
PDFlibPas が SM2、SM3、SM4 を Pascal で作るのは、すでにターゲットとしているどのプラットフォームプロバイダでもこの仕事ができないからです。CryptoAPI と CNG には SM3 署名のサポートがなく、SM2 アイデンティティが存在する前のライブラリは、ダイジェストが SM3 に設定されていると意図的に署名を拒否していました。代替案は、1 つのダイジェスト OID を宣言しながら別の何かへの署名を運ぶ SignerInfo、つまり目に見えるエラーではなく無言の誤署名になるからです
3 つのアルゴリズムには自然な構築順序があり、ライブラリはそれに従いました。最初に SM3。SM2 が ZA アイデンティティハッシュとメッセージダイジェストに必要だからです。次に独立ユニットとして SM4。最後に SM2 で、まず仕様書の付録にある公開サンプル曲線に対して検証された生プリミティブとして、そして Ed25519、Ed448、ML-DSA をすでに扱っている CMS ビルダーに接続された署名アイデンティティとして登場します。同じアプローチは純 Pascal での Ed448 と Brainpool ECDSA 曲線やFIPS 204 ML-DSA ポスト量子署名でも述べており、SM2 はさらに 1 つの鍵タイプとしてそのフレームワークに差し込みます
プリミティブは公開関数なので、PDF 署名の外でも使えます。PDFlibDigest の SM3String と SM3ByteArray は生の 32 バイトダイジェストを返し(16 進ではなく 1 文字 1 バイト)、PDFlibSM4 は 2 つの味で 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 バイトのはずだった 2 つのテスト文字列は実際には 17 バイトで、ブロック整列ヘルパーは設計どおり誤った長さで空文字列を返すため、失敗は例外ではなく不可解な assert として表面化しました
SM2 アイデンティティで PDF に署名する
PDFlibPas の署名プロセスは、SetSignProcessSM2Identity か SetSignProcessSM2IdentityFromFile が証明書と秘密鍵を結び付けると SM2 鍵を使います。証明書は DER で、秘密鍵は生の 32 バイトスカラー、暗号化されていない PKCS#8 コンテナ、SEC 1 の ECPrivateKey のいずれかです。SM2 アイデンティティの結び付けは、同じプロセス上の Ed25519、Ed448、ML-DSA、Brainpool アイデンティティをすべてクリアし、証明書ストア署名と CNG 署名をオフにします。1 つの署名プロセスが競合する 2 つの鍵を運ぶことはありません
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 の戻り値は両ファイルが読まれ署名プロセスに格納されたことを意味します。本当のチェック、秘密スカラーから公開鍵を導出して証明書と比較する処理は、署名の適用時に走ります。そのため、鍵ペアの不一致は identity 呼び出しではなく 1 以外の GetSignProcessResult として現れます
SetSignProcessDigestAlgorithm のコード 8 は SM3 を選択し、SM2 アイデンティティがある場合は事実上必須です。CMS ビルダーはプロセスの要求にかかわらず SM2 鍵には SM3 を強制します。Ed25519 鍵が SHA-512 に強制されるのと同じです。逆の組み合わせも拒否されます。adbe.pkcs7.detached か ETSI.CAdES.detached で SM3 に設定された署名プロセスが SM2 アイデンティティを持たない場合、宣言された OID に合う SM3 ベースの署名を作れる署名者がライブラリにいないため、署名せずに終了します
SM2-with-SM3 の SignerInfo には何が入るのか
PDFlibPas の SM2 署名は DER エンコードされた signed attributes、つまり標準的な CMS 構成に対して計算されますが、SM2 には ECDSA にないステップが 1 つあります。GB/T 32918.2 はメッセージを ZA と一緒にハッシュします。ZA は ZA = SM3(ENTL || ID || a || b || Gx || Gy || xA || yA) と定義されるアイデンティティダイジェストで、曲線の各要素は 32 バイトのビッグエンディアン値、ID は署名者の識別子です。署名は r = (e + x1) mod n と s = ((1 + d)-1(k - r·d)) mod n になり、ライブラリの CSPRNG から新鮮な nonce k を取り、縮退したラウンドは再試行します
PDFlibPas は署名側でも検証側でも、仕様書の既定の識別子である 16 文字の文字列 1234567812345678 を使います。変更する API はありません。これは運用されている SM2 証明書の圧倒的多数と一致しますが、相手がカスタム ID で署名すると、ZA の値が変わり、他のバイトがすべて正しくても署名は検証を通りません
(r, s) のペアは 2 つの 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 で作られた(SetTimeStampOptions ダイジェスト 8 の)RFC 3161 タイムスタンプ要求も同じ SM3 OID を運びます。3.589.1 より前のリリースは未登録の 1.2.156.101.1 アークを書いていました。検証側は今もそれを受け入れるため、それらのビルドで署名された文書は PDFlibPas 内では引き続き有効ですが、サードパーティのバリデータは認識しません。自分のパイプラインを出るものは再署名するのがよい理由です
SM2 検証が壊れていた場所:DER 符号バイトと失われた繰り上がり
SM2 コードの工数の大半は、それらしく見える出力を生んだ 2 つの欠陥に費やされました。どちらも手書き多倍長暗号に典型的で、知ってしまえばどちらも安く避けられます
1 つ目は DER の符号バイトでした。ASN.1 の INTEGER 値は符号付きなので、エンコーダは最上位ビットが立った r と s に正しく 0x00 を前置し、33 バイトの値になります。検証器はそのバイトを剥がさずに各整数を固定幅 32 バイトへ変換していました。r と s の約半分は最上位ビットが立つため、新しく署名した文書のかなりの割合が検証に失敗します。最初の試験走行は偶然短い値を引いて通過しましたが、ランダム nonce で署名する Delphi Win32 のスイートが即座に問題を露呈させました。検証器は今、33 バイトの整数から先頭のゼロを剥がしてから 32 バイトへパディングし、それより長いものは拒否します
2 つ目は 256 ビット加算での失われた繰り上がりでした。体の元は 8 つのリトルエンディアン UInt32 リムで表され、モジュラ加算は「加算し、結果が p 未満でなければ p を引く」と書かれていました。既約の入力では総和は 2p 未満ですが、それでも 2256 を超え得て、超えたとき、落ちた繰り上がりが値を下位 256 ビットへ折り返し、p との比較はすべて正常だと言いました。大きな座標を持つ点の倍化は誤り、小さな倍数は正しい、という症状です。1G から 8G まではリファレンスと一致し、9G、10G、11G は一致しない、というのが見える症状で、修正は 1280 個の加算入出力を独立に計算した値と比較し、最初に分岐したペアを掘り下げることで見つかりました
修正済みの PDFlibSM2 の加算は、生リム加算が返す繰り上がりを保持し、繰り上がりが立っているか結果が p 未満でない場合に 1 回 p を引きます。p は 2255 を超えるため、1 回の減算で十分です。このパターンは 2 の冪直上の任意の素体に当てはまります。あなたの加算ヘルパーが最後の繰り上がりを捨てるなら、1G、2G、3G だけでなく、倍化が最初にワード境界を超えた先の倍数まで広くテストしてください
SM3 が正しくなるまで 2 回間違った話
最初の SM3 実装は標準テストベクトルをすべて落としました。それと並行して書かれた独立のリファレンス実装も同じくです。互いには一致し標準とは一致しない 2 つの実装が指すのは、転写ミスではなく仕様書への共通の誤解です。公開されている SM3 の文と数式を 1 行ずつ突き合わせた結果、コードが記憶から書かれていた箇所が 3 つ見つかりました
- メッセージ拡張は 7 ビット回転を W[j-13] に適用します。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 です
この 3 つの修正の後、空文字列、abc、64 バイトの 2 ブロックベクトルがすべて一致しました。SM4 は一発で入りました。S-box とテストベクトルをコードを書く前に仕様書テキストから取っていたからです。ラウンド鍵 rk0 = F12186F9、単一ブロックベクトル 681EDF34D206965E86B3E94F536E4246、100 万反復のベクトル 595298C7C6FD271F0402F804C33D3F66。SM2 の作業は関連する規則を 1 つ追加しました。長い 16 進定数はグループ化して長さチェックして生成するものであって手で打つものではない、と。手写しした基準点座標が 1 桁欠けて、存在しない実装バグを探すデバッグセッションになったのがきっかけです
SM4 crypt filter で PDF を暗号化する
strength 6 の TPDFlib.Encrypt は、ISO 32000-1 §7.6.5 の crypt filter フレームワークを通じて選択中の文書を SM4-128 で暗号化します。暗号化辞書は標準セキュリティハンドラで /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 の Algorithm 1 に従います。ファイル鍵、オブジェクト番号の下 3 バイト、世代番号の 2 バイト、sAlT 接尾辞を MD5 でハッシュし 16 バイトに切り詰めます。パスワードから鍵への導出は revision 4 のものなので、strength 6 はその限界も引き継ぎます。パスワードは SASLprep 処理されず(AES-256 の strength 3、4、5 のみです)、鍵導出は AESV2 より強くはありません
展開前に知っておくべき相互運用性の限界
正直にまとめると、SM2 署名は上述の OID と既定 ID の境界内で相互運用し、SM4 暗号化は同じ慣習を知るソフトウェアとしか相互運用しません。/SM4 は ISO 32000-1 Table 25 や ISO 32000-2 に定義された /CFM 値の 1 つではありません。そこで定義されるのは None、V2、AESV2、AESV3 です。/CFM /SM4 に出会ったメインストリームの PDF リーダーはファイルを復号できません。PDFlibPas 自体は自前のセキュリティハンドラで /SM4 と変種の /SM4V1 を読むため、PDFlibPas が書いて PDFlibPas が読む閉じたパイプラインは機能します。一般向けのファイルは AES-256 を使うべきです
署名については、境界は証明書のレイアウトと相手側のバリデータです。署名プロセスの背後にあるチェック PLValidateSM2Identity は、SubjectPublicKeyInfo がパラメータとして SM2 曲線 1.2.156.10197.1.301 付きの id-ecPublicKey を運ぶ GM/T 0015 レイアウトと、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 変数をファイナライズし、ルーチンが読む前に入力は空になり、完璧に正常な鍵で identity チェックが失敗しました。鍵を Const で受け取り派生鍵を Out で返す API は、呼び出し側で別々の変数が必要です
FAQ
SM2、SM3、SM4 に Windows CNG や外部 DLL は必要か
いいえ。PDFlibPas は 3 アルゴリズムすべてを Object Pascal で PDFlibDigest、PDFlibSM4、PDFlibSM2 に実装し、SM2 署名はライブラリの CMS コードが構築・検証します。SM2 署名の生成に Windows CryptoAPI と CNG は関与しません
SM3 の代わりに SHA-256 で SM2 を使えるか
いいえ。署名プロセスが SM2 アイデンティティを持つとき、PDFlibPas の CMS ビルダーは常に SM3 を使います。GB/T 32918.2 が、ZA アイデンティティハッシュを含む SM3 ダイジェスト上の SM2 署名を定義しているからです。SM2 署名プロセスでダイジェストを SHA-256 に設定しても結果は変わりません
Adobe Acrobat は PDFlibPas の SM4 暗号化 PDF を開けるか
期待しないでください。/CFM /SM4 crypt filter は ISO 32000 が定義する値の外にあるため、メインストリームのリーダーは認識しません。SM4 暗号化は PDFlibPas か互換実装でファイルを読む閉じたシステム向けに使い、一般配布を意図した文書には AES-256 を使ってください
SM2 署名、SM3 ハッシュ、SM4 暗号化、PAdES 署名 API のフルセットは、Windows と Unix ターゲットで Delphi、C++Builder、FPC/Lazarus でコンパイルできる PDFlibPas(Delphi PDF Library)に同梱されています