PDFlibPas, the PDF library for Delphi and C++Builder, implements the Chinese commercial cryptography suite in pure Object Pascal: SM3 hashing (GB/T 32905), SM4 block encryption (GB/T 32907) and SM2 elliptic curve signatures (GB/T 32918). With them it signs PDFs with SM2-with-SM3 CMS signatures through SetSignProcessSM2Identity and encrypts documents with an SM4 crypt filter through Encrypt with strength 6, with no OS provider or external DLL involved
The usual reason to want this is a procurement requirement, not curiosity. Systems sold into Chinese government, finance and healthcare are increasingly expected to use the national algorithms, and Windows CryptoAPI and CNG do not sign with SM2 or hash with SM3. If the signing path goes through the platform, the answer is simply no. A pure Pascal implementation gives the same answer on Win32, Win64 and the FPC targets, and it gives it without a moving native dependency underneath the document pipeline
Why build the GB/T suite in pure Object Pascal?
PDFlibPas builds SM2, SM3 and SM4 in Pascal because no platform provider it already targets can do the job. CryptoAPI and CNG have no SM3 signature support, so before the SM2 identity existed, the library deliberately refused to sign when the digest was set to SM3: the alternative was a SignerInfo that declares one digest OID and carries a signature over something else, which is a silent wrong signature rather than a visible error
The three algorithms also have a natural build order, and the library followed it. SM3 came first, because SM2 needs it for the ZA identity hash and for the message digest. SM4 came second as an independent unit. SM2 came last, first as raw primitives verified against the published example curve in the specification appendix, then as a signing identity wired into the same CMS builder that already handles Ed25519, Ed448 and ML-DSA. The same approach is described for the Ed448 and Brainpool ECDSA curves in pure Pascal and the FIPS 204 ML-DSA post-quantum signer, and SM2 plugs into that framework as one more key type
The primitives are public functions, so you can use them outside PDF signing. SM3String and SM3ByteArray in PDFlibDigest return the raw 32-byte digest (one byte per character, not hex), and PDFlibSM4 exports ECB and CBC helpers in two flavors: block-aligned versions that return an empty string on a wrong length, and PKCS#7 versions such as SM4EncryptCBCPKCS7 for arbitrary-length data
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 sample: SM3("abc")
Digest := SM3String('abc');
Assert(LowerCase(RawToHex(Digest)) =
'66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0');
// SM4 needs a 16-byte key; CBC needs a 16-byte 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;
Count those 16-byte strings by hand at your peril. Two test strings in the SM4 test suite that were meant to be 16 bytes turned out to be 17, and because the block-aligned helpers return an empty string for a wrong length by design, the failure surfaced as a confusing assertion rather than an exception
Signing a PDF with an SM2 identity
A PDFlibPas sign process uses an SM2 key once SetSignProcessSM2Identity or SetSignProcessSM2IdentityFromFile attaches a certificate and a private key to it. The certificate is DER, and the private key can be a raw 32-byte scalar, an unencrypted PKCS#8 container or a SEC 1 ECPrivateKey. Attaching an SM2 identity clears any Ed25519, Ed448, ML-DSA or Brainpool identity on the same process and switches off certificate-store and CNG signing, so one sign process never carries two competing keys
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;
Note what the return value of SetSignProcessSM2IdentityFromFile does and does not tell you. A return of 1 means both files were read and stored on the sign process. The real check, deriving the public key from the private scalar and comparing it with the certificate, runs when the signature is applied, so a mismatched key pair shows up as a non-1 GetSignProcessResult, not at the identity call
Code 8 in SetSignProcessDigestAlgorithm selects SM3, and with an SM2 identity it is effectively mandatory: the CMS builder forces SM3 for an SM2 key regardless of what the process asked for, the same way an Ed25519 key is forced onto SHA-512. The reverse combination is blocked too. With adbe.pkcs7.detached or ETSI.CAdES.detached, a sign process set to SM3 that does not hold an SM2 identity exits without signing, because no other signer in the library can produce an SM3-based signature that matches the declared OID
What goes into an SM2-with-SM3 SignerInfo?
An SM2 signature in PDFlibPas is computed over the DER-encoded signed attributes, the standard CMS construction, but SM2 adds a step ECDSA does not have. GB/T 32918.2 hashes the message together with ZA, an identity digest defined as ZA = SM3(ENTL || ID || a || b || Gx || Gy || xA || yA), where every curve part is a 32-byte big-endian value and ID is the distinguishing identifier of the signer. The signature is then r = (e + x1) mod n and s = ((1 + d)-1(k - r·d)) mod n, with a fresh nonce k from the library CSPRNG and a retry on degenerate rounds
PDFlibPas uses the default distinguishing identifier from the specification, the 16-character string 1234567812345678, on both the signing and the verification side. There is no API to change it. That matches the overwhelming majority of deployed SM2 certificates, but if a counterparty signs with a custom ID, the ZA values differ and the signature will not verify, even though every other byte is correct
The (r, s) pair goes into the CMS as a DER SEQUENCE of two INTEGER values, the layout every DSA-family SignerInfo uses. PDFlibPas writes the algorithm identifiers registered in GM/T 0006: 1.2.156.10197.1.301 for the SM2 key, 1.2.156.10197.1.401 for SM3 and 1.2.156.10197.1.501 for SM2-with-SM3. RFC 3161 timestamp requests created with SM3 (SetTimeStampOptions digest 8) carry the same SM3 OID. Releases before 3.589.1 wrote an unregistered 1.2.156.101.1 arc; verification still accepts it, so documents signed with those builds keep validating in PDFlibPas, but third-party validators will not recognize them, which is a good reason to re-sign anything that leaves your own pipeline
Where SM2 verification broke: DER sign bytes and a lost carry
Most of the engineering effort in the SM2 code went into two defects that produced plausible output. Both are typical of hand-written big-integer cryptography, and both are cheap to avoid once you know them
The first was a DER sign byte. ASN.1 INTEGER values are signed, so the encoder correctly prepends 0x00 to any r or s whose top bit is set, giving a 33-byte value. The verifier converted each integer to a fixed 32-byte width without stripping that byte first. About half of all r and s values have the top bit set, so a large fraction of freshly signed documents failed verification. The first probe run happened to draw short values and passed; the Delphi Win32 suite, signing with random nonces, exposed the problem immediately. The verifier now deletes a leading zero from any 33-byte integer before padding to 32 bytes, and rejects anything longer
The second was a lost carry in 256-bit addition. Field elements are eight little-endian UInt32 limbs, and the modular add was written as "add, then subtract p if the result is not below p". For reduced inputs the sum is below 2p but can still exceed 2256, and when it did, the dropped carry folded the value back into the low 256 bits, where the comparison against p said everything was fine. Doubling a point with a large coordinate went wrong; small multiples did not. The visible symptom was that 1G through 8G matched the reference while 9G, 10G and 11G did not, and the fix was found by comparing 1280 addition inputs and outputs against independently computed values and drilling into the first divergent pair
The corrected add in PDFlibSM2 keeps the carry returned by the raw limb addition and subtracts p once when the carry is set or the result is not below p; a single subtraction is enough because p exceeds 2255. The pattern applies to any prime field just above a power of two. If your addition helper discards its final carry, test multiples well past the point where doubling first crosses the word boundary, not just 1G, 2G and 3G
Why SM3 was wrong twice before it was right
The first SM3 implementation failed every standard test vector, and so did an independent reference implementation written alongside it. Two implementations that agree with each other and disagree with the standard point at a shared misunderstanding of the specification, not at a transcription error. Comparing the formulas line by line against the published SM3 text found three places where the code had been written from memory
- Message expansion applies the 7-bit rotation to W[j-13]; W[j-6] is a plain XOR term
- SS1 combines rotl(A, 12), E and the rotated round constant with modulo 232 addition, not XOR
- For rounds 16 to 63, FF is the majority function (X AND Y) OR (X AND Z) OR (Y AND Z); the SHA-style choice function belongs to GG
After those three corrections the empty string, abc and the 64-byte two-block vector all matched. SM4 then went in on the first try, because the S-box and the test vectors were taken from the specification text before any code was written: round key rk0 = F12186F9, the single-block vector 681EDF34D206965E86B3E94F536E4246, and the one-million-iteration vector 595298C7C6FD271F0402F804C33D3F66. The SM2 work added a related rule: long hex constants are generated by grouping and length-checked, never typed by hand, after a hand-copied base point coordinate missing one digit sent a debugging session after implementation bugs that did not exist
Encrypting a PDF with the SM4 crypt filter
TPDFlib.Encrypt with strength 6 encrypts the selected document with SM4-128 through the crypt filter framework of ISO 32000-1 §7.6.5. The encryption dictionary uses the standard security handler with /V 4, /R 4 and /Length 128, and the /StdCF crypt filter carries /CFM /SM4. Saving with strength 6 raises the file to at least PDF 1.6, GetEncryptionLevel reports 7 for such a file, and public-key encryption gets the same option through EncryptPublicKey with strength 4
var
Pdf: TPDFlib;
Perm: Integer;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('report.pdf', '') <> 1 then
raise Exception.Create('cannot load PDF');
// print, copy and form filling allowed; no edits or assembly
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;
Each string and stream is encrypted as a 16-byte random IV followed by SM4-CBC ciphertext with PKCS#7 padding, the same layout AESV2 uses. The per-object key follows Algorithm 1 of ISO 32000-1 §7.6.2: the file key, three low bytes of the object number and two bytes of the generation number, plus the sAlT suffix, hashed with MD5 and truncated to 16 bytes. The password-to-key derivation is the revision 4 one, so strength 6 inherits its limits: passwords are not SASLprep-processed (only the AES-256 strengths 3, 4 and 5 are), and the key derivation is no stronger than the AESV2 one
Interoperability limits you should know before deploying
The honest summary is that SM2 signing interoperates within the boundaries of the OIDs and the default ID described above, while SM4 encryption only interoperates with software that knows the same convention. /SM4 is not one of the /CFM values defined in ISO 32000-1 Table 25 or in ISO 32000-2, which define None, V2, AESV2 and AESV3. A mainstream PDF reader that meets /CFM /SM4 will not decrypt the file. PDFlibPas itself reads /SM4 and the variant /SM4V1 through its security handler, so a closed pipeline where PDFlibPas writes and reads works; a file for the general public should use AES-256 instead
For signatures, the boundary is the certificate layout and the validator on the other side. PLValidateSM2Identity, the check behind the sign process, accepts the GM/T 0015 layout, where the SubjectPublicKeyInfo carries id-ecPublicKey with the SM2 curve 1.2.156.10197.1.301 as its parameter, as well as certificates that put the SM2 OID in the algorithm field itself, and it rejects anything whose derived public point does not match the private scalar. Before relying on SM2 signatures in production, sign a document with a certificate from the CA you will actually use and verify it with the validator your counterparty runs. You can run the identity check yourself before starting a sign process:
uses
SysUtils, PDFlibModernSign;
var
CertDER, KeyDER, Scalar: AnsiString;
begin
// CertDER and KeyDER loaded from disk or an HSM export
// Keep the Out parameter in its own variable: passing KeyDER as both
// the Const input and the Out output would clear the input first
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;
That comment records a real defect from the integration work. When the same AnsiString variable was passed as a Const input and an Out output, the compiler finalized the Out variable on entry, the input became empty before the routine read it, and the identity check failed on a perfectly good key. Any API that takes a key as Const and returns a derived key as Out needs separate variables on the caller side
FAQ
Does PDFlibPas need Windows CNG or an external DLL for SM2, SM3 and SM4?
No. PDFlibPas implements all three algorithms in Object Pascal in PDFlibDigest, PDFlibSM4 and PDFlibSM2, and SM2 signatures are built and verified by the library CMS code. Windows CryptoAPI and CNG are not involved in producing the SM2 signature
Can I use SM2 with SHA-256 instead of SM3?
No. When a sign process holds an SM2 identity, the PDFlibPas CMS builder always uses SM3, because GB/T 32918.2 defines SM2 signatures over an SM3 digest that includes the ZA identity hash. Setting the digest to SHA-256 on an SM2 sign process does not change the result
Will Adobe Acrobat open a PDF encrypted with SM4 by PDFlibPas?
Do not expect it to. The /CFM /SM4 crypt filter is outside the values defined by ISO 32000, so mainstream readers do not recognize it. Use SM4 encryption for closed systems that read files with PDFlibPas or a compatible implementation, and AES-256 for documents meant for general distribution
SM2 signing, SM3 hashing, SM4 encryption and the full set of PAdES signing APIs ship with PDFlibPas, the Delphi PDF Library, which compiles with Delphi, C++Builder and FPC/Lazarus on Windows and Unix targets