Zrzut heksowy RSASSA-PSS signatureAlgorithm kończący się A0 0F … A1 1C … 02 01 20 02 01 01 wygląda na kompletny: SHA-256, MGF1, sól 32-bajtowa i trailer równy 1. Ścisły dekoder odrzuca ten blok parametrów w całości, a łagodny czyta długość soli jako 20. W każdym razie podpis, którego arytmetyka RSA jest poprawna, wraca jako nieważny. RFC 4055 §3.1 wyjaśnia dlaczego: każde pole RSASSA-PSS-params niesie jawny tag kontekstowy, więc sól należy do środka A2, a przy DER trailerField równy swojej wartości domyślnej w ogóle nie może wystąpić
To daje podpisowi CMS dwie połowy, które mogą zawieść niezależnie. Jedna to operacja kryptograficzna; druga to ASN.1, który mówi weryfikatorowi, jak operacja została wykonana. Zrób pierwszą dobrze, a drugą źle, i weryfikator trzymający się specyfikacji nie ma jak odróżnić twojego dokumentu od fałszywki. Połowa ASN.1 to ta, którą ten artykuł obejmuje
Dlaczego weryfikator odrzuca podpis RSASSA-PSS, którego bajty są poprawne?
Bo RSASSA-PSS to jedyny schemat RSA, w którym weryfikator nie potrafi wyliczyć parametrów z OID-a algorytmu. PKCS#1 v1.5 jest w pełni przypięty przez sha256WithRSAEncryption, więc jego parametry to goły NULL. PSS jest parametryzowany haszem wiadomości, funkcją generowania maski z własnym haszem i długością soli, a RFC 8017 §A.2.3 zostawia wszystkie trzy sygnatariuszowi. AlgorithmIdentifier niesie wybory sygnatariusza, i weryfikator musi odtworzyć je dokładnie, zanim ruszy EMSA-PSS-VERIFY
Blok, którego weryfikator nie zdoła sparsować, kończy weryfikację, zanim dojdzie do jakiegokolwiek potęgowania modularnego. Blok sparsowany inaczej jest gorszy. RFC 4055 §3.1 daje saltLength domyślnie 20, więc dekoder, który nigdzie nie znajdzie pola soli, puszcza EMSA-PSS-VERIFY z solą 20-bajtową przeciwko podpisowi liczonemu z 32. Wynik jest zgłaszany jako zły podpis, bez niczego wskazującego na metadane. Od ścisłości weryfikatora zależy, którą z dwóch porażek zobaczysz, i żadny komunikat błędu nie wspomina o AlgorithmIdentifier
Cztery pola RSASSA-PSS-params i ich tagi
RFC 4055 §3.1 definiuje RSASSA-PSS-params jako SEQUENCE czterech pól, każde z jawnym tagiem kontekstowym i wartością DEFAULT. W DER tagowanie jawne owija każde pole w skonstruowany kontekstowy TLV — od A0 do A3 — z własnym uniwersalnym kodowaniem pola zagnieżdżonym w środku:
| Pole | Tag (bajt DER) | DEFAULT | Wartość z ETSI TS 119 312 §7 | Kodowane? |
|---|---|---|---|---|
hashAlgorithm | [0] (A0) | sha1 | SHA-256 | Tak |
maskGenAlgorithm | [1] (A1) | mgf1SHA1 | MGF1 z SHA-256 | Tak |
saltLength | [2] (A2) | 20 | 32 (długość digestu) | Tak |
trailerField | [3] (A3) | trailerFieldBC (1) | 1 | Nie, równe domyślnej |
Tagi są tam, bo każde pole jest opcjonalne przez swoją wartość domyślną. hashAlgorithm i maskGenAlgorithm to obie SEQUENCE typu AlgorithmIdentifier; bez tagu dekoder patrzący na blok parametrów trzymający pojedynczą SEQUENCE nie odróżniłby, którą z nich widzi. Z tagami numer tagu nazywa pole, niezależnie od tego, których sąsiadów encoder pominął
Wybierz sól równą długości digestu i trzymaj się jej. Niektóre API oferują „maksymalną sól" albo „długość soli auto", co daje wartość zależną od rozmiaru klucza; to legalne pod RFC 8017, ale AlgorithmIdentifier musi wtedy podawać liczbę zmieniającą się z klucza na klucz, a sól 32-bajtowa dla SHA-256 to i tak to, o co prosi ETSI TS 119 312 §7. Wbudowane backendy podpisujące Komponentu PDFium wszystkie przypinają ją na 32 i podają tę samą liczbę do wywołania platformowego: cbSalt w BCRYPT_PSS_PADDING_INFO podawanym NCryptSignHash na Windows, sLen w CK_RSA_PKCS_PSS_PARAMS dla tokena PKCS#11, EVP_PKEY_CTX_set_rsa_pss_saltlen pod OpenSSL i sól długości digestu, którą implikuje kSecKeyAlgorithmRSASignatureDigestPSSSHA256 na macOS
Pisanie GetSignatureAlgorithmParams dla własnego ICmsSigner
W Komponencie PDFium każdy backend podpisujący implementuje interfejs ICmsSigner z FPdfCms.pas, a blok parametrów pochodzi z jednej metody, GetSignatureAlgorithmParams. Builder CMS paruje swój wynik z OID-em z GetSignatureAlgorithmOid przez TDerWriter.AlgIdWithParams Spotykasz tę metodę, gdy podłączasz własnego sygnatariusza przez ConfigurePadesSignerProvider — sieciowy HSM, usługę podpisującą, sterownik karty chipowej, którego wbudowane backendy nie pokrywają. Kodowanie, które większość pisze najpierw, taguje dwa AlgorithmIdentifier — bo te oczywiście wymagają opakowania — i zostawia dwie liczby całkowite gołe:
// Pospolita pierwsza próba: [0] i [1] z tagiem, [2] i [3] bez
Result := W.Sequence(Concat4(
W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
W.ContextSpecific(1, W.Sequence(ConcatBytes(
W.OID('1.2.840.113549.1.1.8'),
W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
W.IntegerOf(32), // goły INTEGER tam, gdzie wymagane jest [2] EXPLICIT
W.IntegerOf(1))); // trailerField równe DEFAULT: musi nie występować
Przejdź przez to tak, jak robi to dekoder. Widzi A0 i czyta hasz, widzi A1 i czyta funkcję generowania maski, po czym spotyka 02 01 20 — uniwersalny INTEGER. RSASSA-PSS-params nie ma nigdzie nieotagowanego członka INTEGER, więc ścisły dekoder stopuje. Łagodny pomija nieznany element, nigdzie nie znajduje A2, przypisuje domyślną sól 20, a potem potyka się o drugi błądzący INTEGER, 02 01 01. Żadna droga nie kończy na 32
Wersja zgodna używa trzech wywołań TDerWriter.ContextSpecific z FPdfAsn1.pas — po jednym na każde pole niedomyślne, każde z Constructed ustawionym na True, więc wynik jest jawnym opakowaniem. Linii dla pola trailer nie ma. To ten sam blok, który emitują backendy Windows CNG, macOS Keychain, PKCS#11 i OpenSSL, zapisany tutaj tak, jak niósłby go własny sygnatariusz:
uses
System.SysUtils, FPdfSha256, FPdfAsn1, FPdfCms;
type
TMyHsmSigner = class(TInterfacedObject, ICmsSigner)
private
FUsePss: Boolean;
public
function GetDigestAlgorithmOid: AnsiString;
function GetSignatureAlgorithmOid: AnsiString;
function GetSignatureAlgorithmParams: TBytes;
function GetSignerId: TCmsSignerId;
function SignSignedAttrsDigest(const Digest: TSHA256Digest): TBytes;
end;
// Twoja własna klasa; pozostałe cztery metody rozmawiają z twoim HSM i nie są pokazane
function TMyHsmSigner.GetSignatureAlgorithmParams: TBytes;
var
W: TDerWriter;
begin
if not FUsePss then
Exit(nil); // v1.5 i ECDSA: AlgIdWithParams pisze NULL dla nil
W := TDerWriter.Create;
try
// RFC 4055 3.1: [0] hasz, [1] MGF1 z jego haszem, [2] sól.
// [3] trailerField = 1 równa się DEFAULT, więc X.690 11.5 go pomija
Result := W.Sequence(Concat3(
W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
W.ContextSpecific(1, W.Sequence(ConcatBytes(
W.OID('1.2.840.113549.1.1.8'), // id-mgf1
W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
W.ContextSpecific(2, W.IntegerOf(32), True)));
finally
W.Free;
end;
end;
TDerWriter.AlgId zapisuje AlgorithmIdentifier z parametrami NULL — o to właśnie każe encoderom prosić RFC 4055 §2.1 dla zagnieżdżonych identyfikatorów SHA-256. Wynikowe bajty są krótkie na tyle, by sprawdzić je okiem: zewnętrzne 30 34, potem A0 0F wokół 15-bajtowego AlgorithmIdentifier SHA-256, A1 1C wokół 28-bajtowego AlgorithmIdentifier MGF1 i A2 03 02 01 20 dla soli. Sól, którą zapisujesz w GetSignatureAlgorithmParams, musi pasować do tej, której twój HSM faktycznie używa; jeśli usługa wystawia wyłącznie „PSS z SHA-256" i nie mówi ci długości soli, dowiedz się przed wysyłką, bo AlgorithmIdentifier to twierdzenie o niej
Dlaczego trailerField jest pomijany, a nie otagowany jako [3]?
X.690 §11.5 mówi, że encoder DER nie koduje składnika, którego wartość równa się jego DEFAULT. trailerField ma DEFAULT trailerFieldBC, liczbę całkowitą 1, a PSS tak, jak definiuje go RFC 8017, używa wyłącznie tamtego trailera. Więc oczywista naprawa pierwszej próby — podmiana W.IntegerOf(1) na W.ContextSpecific(3, W.IntegerOf(1), True) — daje A3 03 02 01 01, co pobłażliwy dekoder BER przyjmie, a ścisły dekoder DER ma prawo odrzucić. Wartość jest dobra. Jej obecność nie
Ta sama reguła wyjaśnia, dlaczego pozostałe trzy pola się pojawiają: SHA-256 nie jest sha1, MGF1 z SHA-256 nie jest mgf1SHA1, a 32 nie jest 20. Sygnatariusz używający SHA-1 z 20-bajtową solą zwinąłby cały blok do pustej SEQUENCE, 30 00. Ten kształt wywraca każdego, kto zakłada, że „brak parametrów" zawsze zapisuje się jako 05 00; dla id-RSASSA-PSS w CMS pole parametrów jest wymagane, pusta SEQUENCE znaczy „wszystkie domyślne", a NULL nie jest poprawną wartością RSASSA-PSS-params
BER pozwala encoderowi dołączyć składnik o wartości domyślnej; DER tego zakazuje, bo DER istnieje po to, by jedna wartość miała dokładnie jedno kodowanie, a podpis nad strukturą z dwoma legalnymi kodowaniami daje się kwestionować. Blok parametrów nie umyka tu DER-owi. Siedzi w zewnętrznym signatureAlgorithm SignerInfo i jest kopiowany do atrybutów podpisanych przez atrybut cmsAlgorithmProtection z RFC 6211, gdzie każdy bajt jest objęty podpisem
Kontrola CMS-a, którego nie budowałeś
Ta kopia wewnątrz cmsAlgorithmProtection nie łapie źle otagowanego bloku. Atrybut i zewnętrzne signatureAlgorithm pochodzą z tego samego wywołania, więc zawsze pasują, a porównanie struktury z samą sobą przechodzi. Problem łapie dekoder czytający pole parametrów pole po polu. InspectPadesSignatureAlgorithm Komponentu PDFium w FPdfPades.pas robi dokładnie to: przyjmuje wyłącznie A0 do A3, każde najwyżej raz i w rosnącej kolejności, wypełnia domyślne wartości RFC 4055 za wszystko nieobecne i zgłasza efektywne wartości w TPadesRsaPssParameters. Goły INTEGER w bloku zostawia SignaturePss.Valid na False, a EvaluatePadesSignatureAlgorithm ustawia wtedy PolicyStatus na pcsInvalid. Odrzuca też hasz PSS różniący się od algorytmu digestu SignerInfo — dokładnie to, co wyprodukowałaby łagodna odwrotność do sha1
Najbardziej owocuje to przy podpisywaniu zdalnym, gdzie zewnętrzna usługa zwraca gotowy CMS, a CompletePadesRemoteSignature osadza go w przygotowanym PDF. Obejrzyj blob, zanim go osadzisz. Odrzucenie w tym miejscu kosztuje jedno wywołanie API; odkrycie tego z walidatora klienta kosztuje rundę ponownego podpisania z tym, kto prowadzi usługę:
uses
System.SysUtils, FPdfPades;
// Sugerowana kontrola, którą dodajesz sam, przed CompletePadesRemoteSignature
function CheckRemotePssCms(const CmsDer: TBytes; out Why: string): Boolean;
const
OID_RSASSA_PSS = '1.2.840.113549.1.1.10';
var
Info: TPadesSignatureAlgorithmInfo;
Values: string;
begin
Result := False;
Why := '';
// nil: niech inspektor sam zlokalizuje certyfikat sygnatariusza wewnątrz CMS
if not InspectPadesSignatureAlgorithm(CmsDer, nil, Info) then
begin
Why := 'CMS SignerInfo could not be parsed';
Exit;
end;
if Info.SignatureAlgorithmOid = OID_RSASSA_PSS then
begin
if not Info.SignaturePss.Valid then
begin
Why := 'RSASSA-PSS-params does not decode as RFC 4055 3.1';
Exit;
end;
// Wartości efektywne, z domyślnymi: brakujące A2 wychodzi tu jako 20
Why := Format('hash %s, MGF hash %s, salt %d', [
string(Info.SignaturePss.HashAlgorithmOid),
string(Info.SignaturePss.MaskGenHashAlgorithmOid),
Info.SignaturePss.SaltLength]);
end;
if Info.PolicyStatus = pcsInvalid then
Exit; // Why wciąż trzyma zdekodowane wartości dla logu
Values := Why;
Result := VerifyCmsAlgorithmProtection(CmsDer, Why);
if Result then
Why := Values; // kontrola ochrony czyści Why przy sukcesie
end;
Loguj łańcuch Why nawet przy sukcesie. Długość soli 20 obok hasza SHA-256 to odcisk palca bloku parametrów, któremu zginęło A2 — cokolwiek mówią logi samej usługi. Dla szybkiego rzutu oka bez kodu openssl asn1parse -inform DER -in signature.p7s wypisze drzewo; pod OID-em id-RSASSA-PSS chcesz zobaczyć cont [ 2 ] owijające INTEGER o wartości 20 hex i ani śladu cont [ 3 ]. Top-levelowy INTEGER wewnątrz tej SEQUENCE to źle otagowane kodowanie z pierwszego bloku kodu
Gdzie to się mieści w reszcie historii PSS
Kodowanie parametrów to jeden z trzech oddzielnych sposobów, na jakie PSS może się wysypać w podpisie PAdES, i rozdzielenie ich skraca debugowanie. Na macOS konkretny klucz albo starszy system może nie oferować PSS, i backend podpisuje wtedy PKCS#1 v1.5, deklarując dla spójności sha256WithRSAEncryption; to pytanie o możliwości pokrywa podpisywanie PAdES tożsamością z macOS Keychain. Token PKCS#11 może czytać CK_RSA_PKCS_PSS_PARAMS z inną szerokością liczby całkowitej niż ta, którą zapisał wywołujący — pytanie ABI, omówione w CK_ULONG i pułapce pakowania struktur PKCS#11. Trzeci to ten powyżej: klucz jest chętny, token liczy właściwe bajty, a DER je opisujący nie podąża za RFC 4055 §3.1
Zbiór atrybutów podpisanych ma swoją wersję ślepego punktu samozgodności, gdzie SET haszowany w jednej kolejności, a emitowany w innej, wygląda dobrze, dopóki cudzy weryfikator nie przeliczy hasza; zobacz CMS signedAttrs i sortowanie DER SET OF. Dla cyklu przygotuj — podpisz zdalnie — zakończ, w który wpina się kontrola powyżej, zdalne podpisywanie PAdES z HSM pokrywa protokół dwufazowy i to, czego odmawia krok zakończenia
Komponent PDFium dla Delphi wysyła swoje backendy podpisujące jako źródła, więc ciała GetSignatureAlgorithmParams w jednostkach Windows, macOS, PKCS#11 i OpenSSL są tam do przeczytania i do zrzutu obok wyjścia twojego własnego sygnatariusza, bajt w bajt