Artykuł techniczny

Podpisywanie i szyfrowanie PDF z SM2, SM3 i SM4 w Delphi

PDFlibPas, biblioteka PDF dla Delphi i C++Builder, implementuje chiński komercyjny zestaw kryptografii w czystym Object Pascalu: hashowanie SM3 (GB/T 32905), blokowe szyfrowanie SM4 (GB/T 32907) i podpisy krzywą eliptyczną SM2 (GB/T 32918). Za ich pomocą podpisuje PDF-y podpisami CMS SM2-with-SM3 przez SetSignProcessSM2Identity i szyfruje dokumenty filtrem kryptograficznym SM4 przez Encrypt z siłą 6, bez dostawcy systemowego i bez zewnętrznej DLL

Zwykły powód, żeby tego chcieć, to wymóg przetargowy, a nie ciekawość. Od systemów sprzedawanych do chińskiego rządu, finansów i ochrony zdrowia coraz częściej oczekuje się używania algorytmów narodowych, a Windows CryptoAPI i CNG nie podpisują SM2 ani nie hashują SM3. Jeśli ścieżka podpisywania idzie przez platformę, odpowiedź brzmi po prostu nie. Implementacja w czystym Pascalu daje tę samą odpowiedź na Win32, Win64 i celach FPC, i daje ją bez ruchomej natywnej zależności pod potokiem dokumentów

Dlaczego budować zestaw GB/T w czystym Object Pascalu?

PDFlibPas buduje SM2, SM3 i SM4 w Pascalu, bo żaden dostawca platformowy, na który już celuje, nie da rady tego zrobić. CryptoAPI i CNG nie mają wsparcia podpisu SM3, więc zanim istniała tożsamość SM2, biblioteka celowo odmawiała podpisu, gdy skrót był ustawiony na SM3: alternatywą byłby SignerInfo, który deklaruje jeden OID skrótu, a niesie podpis nad czymś innym, czyli cicho zły podpis zamiast widocznego błędu

Trzy algorytmy mają też naturalną kolejność budowy i biblioteka jej poszła. SM3 przyszedł pierwszy, bo SM2 potrzebuje go do hasza tożsamości ZA i do skrótu wiadomości. SM4 przyszedł drugi jako niezależna jednostka. SM2 przyszedł ostatni, najpierw jako surowe prymitywy zweryfikowane względem opublikowanej przykładowej krzywej w aneksie specyfikacji, potem jako tożsamość podpisująca podpięta do tego samego budowniczego CMS, który już obsługuje Ed25519, Ed448 i ML-DSA. To samo podejście jest opisane przy krzywych ECDSA Ed448 i Brainpool w czystym Pascalu i przy postkwantowym podpisującym FIPS 204 ML-DSA, a SM2 wpina się w ten framework jako jeszcze jeden typ klucza

Prymitywy są publicznymi funkcjami, więc możesz ich używać poza podpisywaniem PDF. SM3String i SM3ByteArray w PDFlibDigest zwracają surowy 32-bajtowy skrót (jeden bajt na znak, nie hex), a PDFlibSM4 eksportuje pomocniki ECB i CBC w dwóch smakach: wersje wyrównane do bloku, które zwracają pusty napis przy złej długości, i wersje PKCS#7, jak SM4EncryptCBCPKCS7, dla danych dowolnej długości

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
  // przykład z GB/T 32905: SM3("abc")
  Digest := SM3String('abc');
  Assert(LowerCase(RawToHex(Digest)) =
    '66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0');

  // SM4 potrzebuje 16-bajtowego klucza; CBC potrzebuje 16-bajtowego 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;

Policz te napisy 16-bajtowe ręcznie na własne ryzyko. Dwa napisy testowe w zestawie testów SM4, które miały mieć 16 bajtów, okazały się mieć 17, a ponieważ pomocniki wyrównane do bloku zwracają z założenia pusty napis przy złej długości, porażka wyszła jako myląca asercja, a nie wyjątek

Podpisywanie PDF tożsamością SM2

Proces podpisywania PDFlibPas używa klucza SM2, gdy SetSignProcessSM2Identity albo SetSignProcessSM2IdentityFromFile przyczepi do niego certyfikat i klucz prywatny. Certyfikat jest DER, a klucz prywatny może być surowym 32-bajtowym skalarrem, niezaszyfrowanym pojemnikiem PKCS#8 albo SEC 1 ECPrivateKey. Przyczepienie tożsamości SM2 czyści każdą tożsamość Ed25519, Ed448, ML-DSA albo Brainpool na tym samym procesie i wyłącza podpisywanie ze sklepu certyfikatów i CNG, więc jeden proces podpisywania nigdy nie niesie dwóch konkurujących kluczy

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;

Zwróć uwagę, co wartość zwracana przez SetSignProcessSM2IdentityFromFile mówi, a czego nie mówi. Jedynka w powrocie znaczy, że oba pliki zostały odczytane i przechowane na procesie podpisywania. Prawdziwe sprawdzenie, wyprowadzenie klucza publicznego z prywatnego skalara i porównanie go z certyfikatem, biegnie w chwili nakładania podpisu, więc niedopasowana para kluczy wychodzi jako GetSignProcessResult różny od 1, a nie przy wywołaniu tożsamości

Kod 8 w SetSignProcessDigestAlgorithm wybiera SM3, a przy tożsamości SM2 jest efektywnie obowiązkowy: budowniczy CMS wymusza SM3 dla klucza SM2 bez względu na to, o co prosił proces, tak samo jak klucz Ed25519 jest wymuszany na SHA-512. Odwrotna kombinacja też jest zablokowana. Z adbe.pkcs7.detached albo ETSI.CAdES.detached proces podpisywania ustawiony na SM3, który nie trzyma tożsamości SM2, kończy bez podpisania, bo żaden inny podpisujący w bibliotece nie wyprodukuje podpisu na bazie SM3 zgodnego z zadeklarowanym OID

Co wchodzi do SignerInfo SM2-with-SM3?

Podpis SM2 w PDFlibPas jest liczony nad DER-owo zakodowanymi atrybutami podpisanymi, standardową konstrukcją CMS, ale SM2 dodaje krok, którego ECDSA nie ma. GB/T 32918.2 hashuje wiadomość razem z ZA, haszem tożsamości zdefiniowanym jako ZA = SM3(ENTL || ID || a || b || Gx || Gy || xA || yA), gdzie każda część krzywej to 32-bajtowa wartość big-endian, a ID to wyróżnik tożsamości podpisującego. Podpis to potem r = (e + x1) mod n oraz s = ((1 + d)-1(k - r·d)) mod n, ze świeżą liczbą losową k z bibliotecznego CSPRNG i ponowieniem przy zdegenerowanych rundach

PDFlibPas używa domyślnego wyróżnika ze specyfikacji, 16-znakowego napisu 1234567812345678, po obu stronach, przy podpisywaniu i przy weryfikacji. Nie ma API, by go zmienić. To pasuje do przytłaczającej większości wdrożonych certyfikatów SM2, ale jeśli kontrahent podpisuje własnym ID, wartości ZA się różnią i podpis się nie zweryfikuje, choć każdy inny bajt jest poprawny

Para (r, s) wchodzi do CMS jako DER-owa SEQUENCE dwóch wartości INTEGER, układ, jakiego używa każdy SignerInfo rodziny DSA. PDFlibPas zapisuje identyfikatory algorytmów zarejestrowane w GM/T 0006: 1.2.156.10197.1.301 dla klucza SM2, 1.2.156.10197.1.401 dla SM3 i 1.2.156.10197.1.501 dla SM2-with-SM3. Żądania znacznika czasu RFC 3161 tworzone z SM3 (SetTimeStampOptions skrót 8) niosą ten sam OID SM3. Wydania przed 3.589.1 zapisywały niezarejestrowany łuk 1.2.156.101.1; weryfikacja wciąż go przyjmuje, więc dokumenty podpisane tymi wersjami dalej się walidują w PDFlibPas, ale walidatory stron trzecich ich nie rozpoznają, co jest dobrym powodem, żeby ponownie podpisać wszystko, co opuszcza twój własny potok

Budowa SignerInfo SM2-with-SM3 w PDFlibPas dla Delphi: skrót obejmuje ZA = SM3 z ENTL, ID i parametrów krzywej z domyślnym ID 1234567812345678, r i s idą za GB/T 32918.2 ze świeżą liczbą losową z CSPRNG, a CMS niesie OID-y GM/T 0006: 1.2.156.10197.1.301, 1.2.156.10197.1.401 i 1.2.156.10197.1.501
Wartość podpisu to DER-owa SEQUENCE dwóch INTEGER nad atrybutami podpisanymi; wersje przed 3.589.1 zapisywały niezarejestrowany łuk OID, który walidatory stron trzecich odrzucają, więc ponownie podpisuj wszystko, co opuszcza twój potok

Gdzie weryfikacja SM2 się psuła: bajt znaku DER i zgubione przeniesienie

Większość pracy inżynierskiej w kodzie SM2 poszła na dwie wady, które produkowały wiarygodnie wyglądające wyjście. Obie są typowe dla ręcznie pisanej kryptografii dużych liczb całkowitych i obie są tanie do uniknięcia, gdy się je zna

Pierwszą był bajt znaku DER. Wartości ASN.1 INTEGER są ze znakiem, więc enkoder poprawnie przedkłada 0x00 przed każde r albo s z ustawionym najwyższym bitem, dając wartość 33-bajtową. Weryfikator przeliczał każdą liczbę całkowitą na stałą szerokość 32 bajtów bez wcześniejszego zerwania tego bajtu. Około połowa wszystkich wartości r i s ma ustawiony najwyższy bit, więc spora część świeżo podpisanych dokumentów wypadała w weryfikacji. Pierwszy przebieg kontrolny trafił akurat krótkie wartości i przeszedł; zestaw Delphi Win32, podpisujący losowymi liczbami, obnażył problem natychmiast. Weryfikator usuwa teraz wiodące zero z każdej 33-bajtowej liczby całkowitej przed dopełnieniem do 32 bajtów i odrzuca wszystko dłuższe

Wada weryfikacji podpisu SM2 w PDFlibPas dla Delphi i jej naprawa: kodowanie ASN.1 INTEGER przedkłada 0x00, gdy najwyższy bit r albo s jest ustawiony, stary weryfikator dopełniał wartość 33-bajtową do stałych 32 bajtów bez zerwania bajtu znaku, a poprawiony weryfikator usuwa wiodące zero i odrzuca wszystko dłuższe
Około połowa wartości r i s niesie prefiks 0x00, więc wiarygodnie wyglądające wyjście wypadało w weryfikacji masowo; krótkie wartości kontrolne przeszły pierwszy przebieg, dopóki zestaw Delphi Win32 nie podpisał losowymi liczbami

Drugą było zgubione przeniesienie w dodawaniu 256-bitowym. Elementy ciała to osiem kończyn UInt32 little-endian, a dodawanie modularne było napisane jako „dodaj, potem odejmij p, jeśli wynik nie jest niższy niż p". Dla zredukowanych wejść suma jest poniżej 2p, ale może wciąż przekraczać 2256, a gdy przekraczała, zgubione przeniesienie zawijało wartość z powrotem do niskich 256 bitów, gdzie porównanie z p mówiło, że wszystko gra. Podwajanie punktu o dużej współrzędnej szło źle; małe wielokrotności nie. Widocznym objawem było to, że 1G przez 8G zgadzało się z referencją, a 9G, 10G i 11G nie, a poprawkę znaleziono, porównując 1280 wejść i wyjść dodawania z niezależnie policzonymi wartościami i wiercąc w pierwszą rozjechaną parę

Poprawione dodawanie w PDFlibSM2 trzyma przeniesienie zwrócone przez surowe dodawanie kończyn i odejmuje p raz, gdy przeniesienie jest ustawione albo wynik nie jest niższy niż p; jedno odejmowanie wystarcza, bo p przekracza 2255. Ten wzorzec dotyczy każdego ciała pierwszego tuż nad potęgą dwójki. Jeśli twój pomocnik dodawania wyrzuca końcowe przeniesienie, testuj wielokrotności dobrze po punkcie, gdzie podwajanie pierwszy raz przekracza granicę słowa, a nie tylko 1G, 2G i 3G

Dlaczego SM3 był zły dwa razy, zanim był dobry

Pierwsza implementacja SM3 obalała każdy standardowy wektor testowy, i niezależna implementacja referencyjna pisana obok też. Dwie implementacje, które zgadzają się ze sobą, a niezgadzają ze standardem, wskazują na wspólne nieporozumienie specyfikacji, a nie na błąd przepisywania. Porównanie wzorów linia po linii z opublikowanym tekstem SM3 znalazło trzy miejsca, gdzie kod był pisany z pamięci

  • Rozszerzanie wiadomości nakłada rotację 7-bitową na W[j-13]; W[j-6] to zwykły składnik XOR
  • SS1 łączy rotl(A, 12), E i obróconą stałą rundy dodawaniem modulo 232, a nie XOR-em
  • Dla rund 16-63 FF to funkcja większości (X AND Y) OR (X AND Z) OR (Y AND Z); funkcja wyboru w stylu SHA należy do GG

Po tych trzech poprawkach pusty napis, abc i 64-bajtowy wektor dwublokowy wszystkie się zgadzały. SM4 wszedł potem za pierwszym razem, bo S-box i wektory testowe były wzięte z tekstu specyfikacji, zanim napisano jakikolwiek kod: klucz rundy rk0 = F12186F9, wektor jednoblokowy 681EDF34D206965E86B3E94F536E4246 i wektor miliona iteracji 595298C7C6FD271F0402F804C33D3F66. Prace nad SM2 dodały pokrewną regułę: długie stałe hex są generowane grupowaniem i sprawdzane długością, nigdy przepisywane ręcznie, po tym jak ręcznie skopiowana współrzędna punktu bazowego z brakującą cyfrą posłała sesję debugowania za błędami implementacji, które nie istniały

Szyfrowanie PDF filtrem kryptograficznym SM4

TPDFlib.Encrypt z siłą 6 szyfruje wybrany dokument SM4-128 przez framework filtrów kryptograficznych z ISO 32000-1 §7.6.5. Słownik szyfrowania używa standardowego handlera bezpieczeństwa z /V 4, /R 4 i /Length 128, a filtr kryptograficzny /StdCF niesie /CFM /SM4. Zapis z siłą 6 podnosi plik do co najmniej PDF 1.6, GetEncryptionLevel zgłasza dla takiego pliku 7, a szyfrowanie kluczem publicznym dostaje tę samą opcję przez EncryptPublicKey z siłą 4

var
  Pdf: TPDFlib;
  Perm: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    if Pdf.LoadFromFile('report.pdf', '') <> 1 then
      raise Exception.Create('cannot load PDF');
    // druk, kopiowanie i wypełnianie formularzy dozwolone; bez edycji i składania
    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;

Każdy napis i strumień jest szyfrowany jako 16-bajtowa losowa wartość IV, po której idzie szyfrogram SM4-CBC z dopełnieniem PKCS#7, ten sam układ, jakiego używa AESV2. Klucz per obiekt idzie za Algorytmem 1 z ISO 32000-1 §7.6.2: klucz pliku, trzy niskie bajty numeru obiektu i dwa bajty numeru generacji, plus przyrostek sAlT, haszowane MD5 i przycięte do 16 bajtów. Wyprowadzanie klucza z hasła to wersja z rewizji 4, więc siła 6 dziedziczy jej granice: hasła nie są przetwarzane SASLprep (tylko siły AES-256, czyli 3, 4 i 5), a wyprowadzanie klucza nie jest silniejsze niż AESV2

Struktura filtra kryptograficznego SM4 zapisywana przez PDFlibPas Encrypt z siłą 6 w Delphi: /V 4, /R 4 i /Length 128 z filtrem /StdCF ustawionym na /CFM /SM4, klucz per obiekt z MD5 nad kluczem pliku, numerami obiektu i generacji plus sAlT przyciętym do 16 bajtów, a każdy strumień przechowany jako 16-bajtowe IV, po którym idzie szyfrogram SM4-CBC z dopełnieniem PKCS#7
Napisy i strumienie używają układu bajtów AESV2 z rdzeniem SM4, ale /SM4 jest poza wartościami /CFM zdefiniowanymi w ISO 32000, więc mainstreamowe czytniki nie odszyfrują, a dystrybucja ogólna powinna zostać przy AES-256

Granice współpracy, które trzeba znać przed wdrożeniem

Uczciwe podsumowanie brzmi: podpisywanie SM2 współpracuje w granicach OID-ów i domyślnego ID opisanych wyżej, podczas gdy szyfrowanie SM4 współpracuje wyłącznie z oprogramowaniem znającym tę samą konwencję. /SM4 nie jest jedną z wartości /CFM zdefiniowanych w tabeli 25 ISO 32000-1 ani w ISO 32000-2, które definiują None, V2, AESV2 i AESV3. Mainstreamowy czytnik PDF, który spotka /CFM /SM4, nie odszyfruje pliku. Sam PDFlibPas czyta /SM4 i wariant /SM4V1 przez swój handler bezpieczeństwa, więc zamknięty potok, w którym PDFlibPas pisze i czyta, działa; plik dla szerokiej publiczności powinien używać AES-256

Dla podpisów granicą jest układ certyfikatu i walidator po drugiej stronie. PLValidateSM2Identity, sprawdzenie za procesem podpisywania, przyjmuje układ GM/T 0015, gdzie SubjectPublicKeyInfo niesie id-ecPublicKey z krzywą SM2 1.2.156.10197.1.301 jako parametrem, a także certyfikaty, które kładą OID SM2 w samym polu algorytmu, i odrzuca wszystko, czego wyprowadzony punkt publiczny nie pasuje do prywatnego skalara. Zanim zaczniesz polegać na podpisach SM2 w produkcji, podpisz dokument certyfikatem od CA, którego faktycznie użyjesz, i zweryfikuj go walidatorem, który biega u twojego kontrahenta. Sprawdzenie tożsamości możesz uruchomić sam przed startem procesu podpisywania:

uses
  SysUtils, PDFlibModernSign;

var
  CertDER, KeyDER, Scalar: AnsiString;
begin
  // CertDER i KeyDER wczytane z dysku albo z eksportu HSM
  // Trzymaj parametr Out w osobnej zmiennej: przekazanie KeyDER jako
  // wejścia Const i wyjścia Out skasowałoby najpierw wejście
  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;

Ten komentarz zapisuje prawdziwą wadę z prac integracyjnych. Gdy ta sama zmienna AnsiString była przekazywana jako wejście Const i wyjście Out, kompilator finalizował zmienną Out na wejściu do procedury, wejście stawało się puste, zanim procedura je czytała, i sprawdzenie tożsamości wypadało na całkiem dobrym kluczu. Każde API, które przyjmuje klucz jako Const i zwraca wyprowadzony klucz jako Out, potrzebuje osobnych zmiennych po stronie wołającego

FAQ

Czy PDFlibPas potrzebuje Windows CNG albo zewnętrznej DLL dla SM2, SM3 i SM4?

Nie. PDFlibPas implementuje wszystkie trzy algorytmy w Object Pascalu w PDFlibDigest, PDFlibSM4 i PDFlibSM2, a podpisy SM2 są budowane i weryfikowane przez kod CMS biblioteki. Windows CryptoAPI i CNG nie mają udziału w wytwarzaniu podpisu SM2

Czy można użyć SM2 z SHA-256 zamiast SM3?

Nie. Gdy proces podpisywania trzyma tożsamość SM2, budowniczy CMS PDFlibPas zawsze używa SM3, bo GB/T 32918.2 definiuje podpisy SM2 nad skrótem SM3 zawierającym hasz tożsamości ZA. Ustawienie skrótu na SHA-256 przy procesie podpisywania SM2 nie zmienia wyniku

Czy Adobe Acrobat otworzy PDF zaszyfrowany SM4 przez PDFlibPas?

Nie licz na to. Filtr kryptograficzny /CFM /SM4 jest poza wartościami zdefiniowanymi w ISO 32000, więc mainstreamowe czytniki go nie rozpoznają. Używaj szyfrowania SM4 dla zamkniętych systemów czytających pliki PDFlibPasem albo zgodną implementacją, a AES-256 dla dokumentów przeznaczonych do ogólnej dystrybucji

Podpisywanie SM2, hashowanie SM3, szyfrowanie SM4 i pełen zestaw API podpisywania PAdES są w PDFlibPas, Delphi PDF Library, która kompiluje z Delphi, C++Builder i FPC/Lazarus na Windows i celach Unix