Technischer Artikel

SM2, SM3 und SM4 in Delphi: Signieren und Verschlüsseln

PDFlibPas, die PDF-Bibliothek für Delphi und C++Builder, implementiert die chinesische Commercial-Cryptography-Suite in purem Object Pascal: SM3-Hashing (GB/T 32905), SM4-Blockverschlüsselung (GB/T 32907) und SM2-Elliptic-Curve-Signaturen (GB/T 32918). Damit signiert es PDFs mit SM2-with-SM3-CMS-Signaturen über SetSignProcessSM2Identity und verschlüsselt Dokumente mit einem SM4-Crypt-Filter über Encrypt mit Stärke 6, ohne dass ein OS-Provider oder eine externe DLL beteiligt ist

Der übliche Grund für dieses Bedürfnis ist eine Beschaffungsanforderung, keine Neugier. Von Systemen, die in chinesische Regierung, Finanz- und Gesundheitswesen verkauft werden, wird zunehmend erwartet, dass sie die nationalen Algorithmen verwenden, und Windows CryptoAPI und CNG signieren nicht mit SM2 und hashen nicht mit SM3. Läuft der Signierpfad über die Plattform, lautet die Antwort schlicht nein. Eine reine Pascal-Implementierung liefert dieselbe Antwort auf Win32, Win64 und den FPC-Zielen, und zwar ohne eine bewegliche native Abhängigkeit unter der Dokumenten-Pipeline

Warum die GB/T-Suite in purem Object Pascal bauen?

PDFlibPas baut SM2, SM3 und SM4 in Pascal, weil kein Plattform-Provider, den es bereits ansteuert, den Job erledigen kann. CryptoAPI und CNG haben keine SM3-Signaturunterstützung, also lehnte die Bibliothek vor Existenz der SM2-Identität das Signieren bewusst ab, wenn der Digest auf SM3 stand: Die Alternative wäre ein SignerInfo gewesen, das eine Digest-OID deklariert und eine Signatur über etwas anderes trägt — eine stille falsche Signatur statt eines sichtbaren Fehlers

Die drei Algorithmen haben zudem eine natürliche Bau-Reihenfolge, und die Bibliothek ist ihr gefolgt. SM3 kam zuerst, denn SM2 braucht es für den ZA-Identitäts-Hash und für den Message-Digest. SM4 kam als Zweites, als unabhängige Unit. SM2 kam zuletzt, zuerst als rohe Primitive, verifiziert gegen die veröffentlichte Beispielkurve im Spezifikationsanhang, dann als Signing-Identity, angeschlossen an denselben CMS-Builder, der bereits Ed25519, Ed448 und ML-DSA behandelt. Denselben Ansatz beschreiben die Ed448- und Brainpool-ECDSA-Kurven in purem Pascal und der FIPS-204-ML-DSA-Post-Quantum-Signer, und SM2 steckt als ein weiterer Schlüsseltyp in dieses Framework

Die Primitive sind öffentliche Funktionen, also lassen sie sich außerhalb des PDF-Signierens nutzen. SM3String und SM3ByteArray in PDFlibDigest geben den rohen 32-Byte-Digest zurück (ein Byte pro Zeichen, nicht hex), und PDFlibSM4 exportiert ECB- und CBC-Helfer in zwei Ausführungen: block-alignierte Versionen, die bei falscher Länge einen leeren String zurückgeben, und PKCS#7-Versionen wie SM4EncryptCBCPKCS7 für Daten beliebiger Länge

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-Beispiel: SM3("abc")
  Digest := SM3String('abc');
  Assert(LowerCase(RawToHex(Digest)) =
    '66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0');

  // SM4 braucht einen 16-Byte-Schlüssel; CBC braucht einen 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;

Zählen Sie diese 16-Byte-Strings von Hand nach — auf eigene Gefahr. Zwei Teststrings in der SM4-Testsuite, die 16 Byte sein sollten, waren tatsächlich 17, und weil die block-alignierten Helfer bei falscher Länge per Design einen leeren String zurückgeben, zeigte sich der Fehlschlag als verwirrende Assertion statt als Exception

Ein PDF mit einer SM2-Identität signieren

Ein PDFlibPas-Signierprozess nutzt einen SM2-Schlüssel, sobald SetSignProcessSM2Identity oder SetSignProcessSM2IdentityFromFile ihm ein Zertifikat und einen privaten Schlüssel zuordnet. Das Zertifikat ist DER, und der private Schlüssel darf ein roher 32-Byte-Skalar, ein unverschlüsselter PKCS#8-Container oder ein SEC-1-ECPrivateKey sein. Das Anfügen einer SM2-Identität löscht jede Ed25519-, Ed448-, ML-DSA- oder Brainpool-Identität auf demselben Prozess und schaltet Zertifikatsspeicher- und CNG-Signierung ab, sodass ein Signierprozess nie zwei konkurrierende Schlüssel trägt

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;

Beachten Sie, was der Rückgabewert von SetSignProcessSM2IdentityFromFile verrät und was nicht. Eine 1 bedeutet, dass beide Dateien gelesen und am Signierprozess gespeichert wurden. Die echte Prüfung — den öffentlichen Schlüssel aus dem privaten Skalar abzuleiten und mit dem Zertifikat zu vergleichen — läuft, wenn die Signatur angewendet wird; ein unpassendes Schlüsselpaar zeigt sich also als Nicht-1-GetSignProcessResult, nicht beim Identitätsaufruf

Code 8 in SetSignProcessDigestAlgorithm wählt SM3, und mit einer SM2-Identität ist das faktisch Pflicht: Der CMS-Builder erzwingt SM3 für einen SM2-Schlüssel, egal was der Prozess verlangt hat, genauso wie ein Ed25519-Schlüssel auf SHA-512 gezwungen wird. Die umgekehrte Kombination ist ebenfalls blockiert. Mit adbe.pkcs7.detached oder ETSI.CAdES.detached bricht ein auf SM3 gestellter Signierprozess ohne SM2-Identität ohne Signatur ab, denn kein anderer Signierer der Bibliothek kann eine SM3-basierte Signatur erzeugen, die zur deklarierten OID passt

Was steckt in einem SM2-with-SM3-SignerInfo?

Eine SM2-Signatur wird in PDFlibPas über die DER-kodierten Signed Attributes berechnet, die Standard-CMS-Konstruktion, aber SM2 ergänzt einen Schritt, den ECDSA nicht hat. GB/T 32918.2 hasht die Nachricht zusammen mit ZA, einem Identitäts-Digest, definiert als ZA = SM3(ENTL || ID || a || b || Gx || Gy || xA || yA), wobei jeder Kurvenbestandteil ein 32-Byte-Big-Endian-Wert ist und ID der unterscheidende Bezeichner des Unterzeichners. Die Signatur ist dann r = (e + x1) mod n und s = ((1 + d)-1(k - r·d)) mod n, mit einer frischen Nonce k aus der CSPRNG der Bibliothek und einem Wiederholungsversuch bei degenerierten Runden

PDFlibPas verwendet den voreingestellten unterscheidenden Bezeichner aus der Spezifikation, den 16-Zeichen-String 1234567812345678, auf der Signier- wie auf der Verifikationsseite. Es gibt keine API, ihn zu ändern. Das entspricht der überwältigenden Mehrheit der eingesetzten SM2-Zertifikate, aber signiert eine Gegenstelle mit einer eigenen ID, unterscheiden sich die ZA-Werte, und die Signatur verifiziert nicht, obwohl jedes andere Byte korrekt ist

Das Paar (r, s) geht als DER-SEQUENCE aus zwei INTEGER-Werten in das CMS ein, das Layout, das jedes SignerInfo der DSA-Familie verwendet. PDFlibPas schreibt die in GM/T 0006 registrierten Algorithmus-Identifiers: 1.2.156.10197.1.301 für den SM2-Schlüssel, 1.2.156.10197.1.401 für SM3 und 1.2.156.10197.1.501 für SM2-with-SM3. RFC-3161-Timestamp-Requests, erzeugt mit SM3 (SetTimeStampOptions, Digest 8), tragen dieselbe SM3-OID. Releases vor 3.589.1 schrieben einen nicht registrierten 1.2.156.101.1-Arc; die Verifikation akzeptiert ihn weiterhin, also validieren mit diesen Builds signierte Dokumente in PDFlibPas weiter, aber Validatoren Dritter erkennen sie nicht — ein guter Grund, alles, was die eigene Pipeline verlässt, neu zu signieren

Konstruktion des SM2-with-SM3-SignerInfo in PDFlibPas für Delphi: der Digest deckt ZA = SM3 aus ENTL, ID und den Kurvenparametern mit Default-ID 1234567812345678 ab, r und s folgen GB/T 32918.2 mit einer frischen CSPRNG-Nonce, und das CMS trägt die GM/T-0006-OIDs 1.2.156.10197.1.301, 1.2.156.10197.1.401 und 1.2.156.10197.1.501
Der Signaturwert ist eine DER-SEQUENCE aus zwei INTEGERs über die Signed Attributes; Builds vor 3.589.1 schrieben einen nicht registrierten OID-Arc, den Validatoren Dritter abweisen — signieren Sie also alles neu, was Ihre Pipeline verlässt

Wo die SM2-Verifikation brach: DER-Signaturbytes und ein verlorener Carry

Der Großteil des Engineering-Aufwands im SM2-Code floss in zwei Defekte, die plausible Ausgaben erzeugten. Beide sind typisch für handgeschriebene Big-Integer-Kryptografie, und beide lassen sich billig vermeiden, sobald man sie kennt

Der erste war ein DER-Signaturbyte. ASN.1-INTEGER-Werte sind vorzeichenbehaftet, also stellt der Encoder korrekt 0x00 vor jedes r oder s mit gesetztem Top-Bit, was einen 33-Byte-Wert ergibt. Der Verifizierer wandelte jede Ganzzahl in eine feste 32-Byte-Breite um, ohne dieses Byte zuvor abzuschneiden. Etwa die Hälfte aller r- und s-Werte hat das Top-Bit gesetzt, also scheiterte ein großer Anteil frisch signierter Dokumente an der Verifikation. Der erste Probe-Lauf zufällig kurze Werte und bestand; die Delphi-Win32-Suite, signierend mit zufälligen Nonces, legte das Problem sofort offen. Der Verifizierer löscht jetzt eine führende Null aus jeder 33-Byte-Ganzzahl, bevor er auf 32 Byte auffüllt, und weist alles Längere ab

SM2-Signaturverifikationsdefekt in PDFlibPas für Delphi und seine Behebung: die ASN.1-INTEGER-Kodierung stellt 0x00 voran, wenn das Top-Bit von r oder s gesetzt ist, der alte Verifizierer füllte den 33-Byte-Wert auf feste 32 Bytes auf, ohne das Signaturbyte abzuschneiden, und der korrigierte Verifizierer löscht die führende Null und weist alles Längere ab
Rund die Hälfte aller r- und s-Werte trägt das 0x00-Präfix, also scheiterte plausibel aussehende Ausgabe massenhaft an der Verifikation; kurze Probe-Werte bestanden den ersten Lauf, bis die Delphi-Win32-Suite mit zufälligen Nonces signierte

Das zweite war ein verlorener Carry in der 256-Bit-Addition. Körper-Elemente sind acht Little-Endian-UInt32-Limbs, und die modulare Addition war als "addiere, dann subtrahiere p, wenn das Ergebnis nicht unter p liegt" geschrieben. Für reduzierte Eingaben liegt die Summe unter 2p, kann aber trotzdem 2256 überschreiten, und wenn sie das tat, faltete der verlorene Carry den Wert zurück in die unteren 256 Bits, wo der Vergleich gegen p sagte, alles sei in Ordnung. Die Verdopplung eines Punkts mit großer Koordinate ging schief; kleine Vielfache nicht. Das sichtbare Symptom war, dass 1G bis 8G zur Referenz passten, während 9G, 10G und 11G es nicht taten, und die Fixierung fand sich, indem 1280 Additions-Eingaben und -Ausgaben gegen unabhängig berechnete Werte verglichen und in das erste divergierende Paar hineingebohrt wurden

Die korrigierte Addition in PDFlibSM2 behält den Carry, den die rohe Limb-Addition zurückgibt, und subtrahiert p einmal, wenn der Carry gesetzt ist oder das Ergebnis nicht unter p liegt; eine einzige Subtraktion reicht, weil p über 2255 liegt. Das Muster passt auf jeden Primkörper knapp über einer Zweierpotenz. Wenn Ihr Additions-Helfer seinen finalen Carry wegwirft, testen Sie Vielfache weit über den Punkt hinaus, an dem die Verdopplung erstmals die Wortgrenze überschreitet, nicht nur 1G, 2G und 3G

Warum SM3 zweimal falsch war, bevor es richtig war

Die erste SM3-Implementierung scheiterte an jedem Standard-Testvektor, und eine unabhängige Referenzimplementierung, die daneben entstand, ebenso. Zwei Implementierungen, die sich gegenseitig bestätigen und der Norm widersprechen, zeigen auf ein gemeinsames Missverständnis der Spezifikation, nicht auf einen Übertragungsfehler. Der Formelvergleich Zeile für Zeile gegen den veröffentlichten SM3-Text fand drei Stellen, an denen der Code aus dem Gedächtnis geschrieben worden war

  • Die Message-Expansion wendet die 7-Bit-Rotation auf W[j-13] an; W[j-6] ist ein schlichter XOR-Term
  • SS1 kombiniert rotl(A, 12), E und die rotierte Rundkonstante mit Addition modulo 232, nicht mit XOR
  • Für die Runden 16 bis 63 ist FF die Mehrheitsfunktion (X AND Y) OR (X AND Z) OR (Y AND Z); die SHA-typische Auswahlfunktion gehört zu GG

Nach diesen drei Korrekturen passten der leere String, abc und der 64-Byte-Zweiblock-Vektor. SM4 saß dann beim ersten Versuch, weil S-Box und Testvektoren vor jeder Codezeile aus dem Spezifikationstext übernommen worden waren: Rundenschlüssel rk0 = F12186F9, der Einzelblock-Vektor 681EDF34D206965E86B3E94F536E4246 und der Ein-Million-Iterationen-Vektor 595298C7C6FD271F0402F804C33D3F66. Die SM2-Arbeit ergänzte eine verwandte Regel: lange Hex-Konstanten werden durch Gruppierung erzeugt und längengeprüft, nie von Hand getippt — nachdem eine handkopierte Basispunktkoordinate mit einer fehlenden Ziffer eine Debugging-Session hinter Implementierungsfehlern herjagte, die gar nicht existierten

Ein PDF mit dem SM4-Crypt-Filter verschlüsseln

TPDFlib.Encrypt mit Stärke 6 verschlüsselt das ausgewählte Dokument mit SM4-128 über das Crypt-Filter-Framework von ISO 32000-1 §7.6.5. Das Encryption-Dictionary verwendet den Standard-Security-Handler mit /V 4, /R 4 und /Length 128, und der /StdCF-Crypt-Filter trägt /CFM /SM4. Das Speichern mit Stärke 6 hebt die Datei auf mindestens PDF 1.6, GetEncryptionLevel meldet 7 für eine solche Datei, und die Public-Key-Verschlüsselung bekommt dieselbe Option über EncryptPublicKey mit Stärke 4

var
  Pdf: TPDFlib;
  Perm: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('report.pdf', '') <> 1 then
      raise Exception.Create('cannot load PDF');
    // Drucken, Kopieren und Formularausfüllen erlaubt; keine Bearbeitung, keine Zusammenstellung
    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;

Jeder String und Stream wird als 16-Byte-Zufalls-IV gefolgt von SM4-CBC-Chiffretext mit PKCS#7-Padding verschlüsselt, dasselbe Layout, das AESV2 verwendet. Der Objektschlüssel folgt Algorithmus 1 aus ISO 32000-1 §7.6.2: der Dateischlüssel, drei niedrige Bytes der Objektnummer und zwei Bytes der Generierungsnummer, plus das sAlT-Suffix, gehasht mit MD5 und auf 16 Bytes gekürzt. Die Passwort-zu-Schlüssel-Ableitung ist die von Revision 4, also erbt Stärke 6 deren Grenzen: Passwörter werden nicht SASLprep-verarbeitet (nur die AES-256-Stärken 3, 4 und 5 werden es), und die Schlüsselableitung ist nicht stärker als die von AESV2

Struktur des von PDFlibPas Encrypt mit Stärke 6 in Delphi geschriebenen SM4-Crypt-Filters: /V 4, /R 4 und /Length 128 mit dem /StdCF-Filter auf /CFM /SM4, ein Objektschlüssel per MD5 über Dateischlüssel, Objekt- und Generierungsnummer plus sAlT, gekürzt auf 16 Bytes, und jeder Stream gespeichert als 16-Byte-IV gefolgt von SM4-CBC-Chiffretext mit PKCS#7-Padding
Strings und Streams verwenden das AESV2-Byte-Layout mit einem SM4-Kern, aber /SM4 liegt außerhalb der /CFM-Werte, die ISO 32000 definiert, also können gängige Reader nicht entschlüsseln, und die allgemeine Verteilung sollte bei AES-256 bleiben

Interop-Grenzen, die Sie vor dem Deployment kennen sollten

Die ehrliche Zusammenfassung lautet: SM2-Signierung interoperiert innerhalb der Grenzen der oben beschriebenen OIDs und der Default-ID, während SM4-Verschlüsselung nur mit Software interoperiert, die dieselbe Konvention kennt. /SM4 ist keiner der /CFM-Werte, die ISO 32000-1 Tabelle 25 oder ISO 32000-2 definieren — nämlich None, V2, AESV2 und AESV3. Ein gängiger PDF-Reader, der auf /CFM /SM4 trifft, wird die Datei nicht entschlüsseln. PDFlibPas selbst liest /SM4 und die Variante /SM4V1 über seinen Security-Handler, also funktioniert eine geschlossene Pipeline, in der PDFlibPas schreibt und liest; eine Datei für die Allgemeinheit sollte stattdessen AES-256 verwenden

Bei Signaturen ist die Grenze das Zertifikatslayout und der Validator auf der Gegenseite. PLValidateSM2Identity, die Prüfung hinter dem Signierprozess, akzeptiert das GM/T-0015-Layout, in dem das SubjectPublicKeyInfo id-ecPublicKey mit der SM2-Kurve 1.2.156.10197.1.301 als Parameter trägt, ebenso Zertifikate, die die SM2-OID direkt im Algorithmusfeld ablegen, und es weist alles zurück, dessen abgeleiteter öffentlicher Punkt nicht zum privaten Skalar passt. Bevor Sie sich in Produktion auf SM2-Signaturen verlassen, signieren Sie ein Dokument mit einem Zertifikat der CA, die Sie tatsächlich nutzen, und verifizieren Sie es mit dem Validator, den Ihre Gegenstelle betreibt. Die Identitätsprüfung können Sie selbst ausführen, bevor ein Signierprozess startet:

uses
  SysUtils, PDFlibModernSign;

var
  CertDER, KeyDER, Scalar: AnsiString;
begin
  // CertDER und KeyDER von Festplatte oder HSM-Export geladen
  // Den Out-Parameter in eine eigene Variable packen: KeyDER zugleich als
  // Const-Eingabe und Out-Ausgabe zu übergeben leert die Eingabe zuerst
  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;

Dieser Kommentar hält einen echten Defekt aus der Integrationsarbeit fest. Wurde dieselbe AnsiString-Variable als Const-Eingabe und als Out-Ausgabe übergeben, finalisierte der Compiler die Out-Variable beim Eintritt, die Eingabe war leer, bevor die Routine sie las, und die Identitätsprüfung scheiterte an einem völlig einwandfreien Schlüssel. Jede API, die einen Schlüssel als Const nimmt und einen abgeleiteten Schlüssel als Out zurückgibt, braucht auf Aufruferseite getrennte Variablen

FAQ

Braucht PDFlibPas Windows CNG oder eine externe DLL für SM2, SM3 und SM4?

Nein. PDFlibPas implementiert alle drei Algorithmen in Object Pascal in PDFlibDigest, PDFlibSM4 und PDFlibSM2, und SM2-Signaturen werden vom Bibliotheks-CMS-Code erzeugt und verifiziert. Windows CryptoAPI und CNG sind an der Erzeugung der SM2-Signatur nicht beteiligt

Kann ich SM2 mit SHA-256 statt SM3 verwenden?

Nein. Hält ein Signierprozess eine SM2-Identität, verwendet der PDFlibPas-CMS-Builder stets SM3, denn GB/T 32918.2 definiert SM2-Signaturen über einen SM3-Digest, der den ZA-Identitäts-Hash einschließt. Den Digest auf SHA-256 zu stellen, ändert an einem SM2-Signierprozess das Ergebnis nicht

Öffnet Adobe Acrobat ein mit SM4 von PDFlibPas verschlüsseltes PDF?

Erwarten Sie es nicht. Der /CFM /SM4-Crypt-Filter liegt außerhalb der von ISO 32000 definierten Werte, gängige Reader erkennen ihn also nicht. Verwenden Sie SM4-Verschlüsselung für geschlossene Systeme, die Dateien mit PDFlibPas oder einer kompatiblen Implementierung lesen, und AES-256 für Dokumente, die für die allgemeine Verteilung bestimmt sind

SM2-Signierung, SM3-Hashing, SM4-Verschlüsselung und der volle Satz an PAdES-Signing-APIs kommen mit PDFlibPas, der Delphi PDF Library, die mit Delphi, C++Builder und FPC/Lazarus auf Windows- und Unix-Zielen kompiliert