Technisch artikel

RSASSA-PSS-params coderen volgens RFC 4055 in Delphi

Een hexdump van een RSASSA-PSS-signatureAlgorithm die eindigt op A0 0F … A1 1C … 02 01 20 02 01 01 oogt compleet: SHA-256, MGF1, een salt van 32 bytes en een trailer van 1. Een strenge decoder wijst dat parametersblok ronduit af, en een soepelen leest de saltlengte als 20. In beide gevallen komt een handtekening waarvan de RSA-rekenkunde klopt als ongeldig terug. RFC 4055 §3.1 legt uit waarom: elk veld van RSASSA-PSS-params draagt een expliciete contexttag, dus de salt hoort binnen A2, en onder DER mag een trailerField gelijk aan zijn default helemaal niet verschijnen

Daarmee heeft een CMS-handtekening twee helften die onafhankelijk kunnen falen. De ene is de cryptografische operatie; de andere is de ASN.1 die de verifier vertelt hoe de operatie werd uitgevoerd. De eerste goed en de tweede fout krijgen, en een specificatievolgende verifier heeft geen enkele manier om uw document van een vervalsing te onderscheiden. De ASN.1-helft is degene die dit artikel behandelt

Waarom weigert een verifier een RSASSA-PSS-handtekening waarvan de bytes correct zijn?

Omdat RSASSA-PSS het enige RSA-schema is waarbij de verifier de parameters niet uit de algoritme-OID kan afleiden. PKCS#1 v1.5 wordt volledig vastgezet door sha256WithRSAEncryption, dus zijn parameters zijn een kale NULL. PSS wordt geparameteriseerd door een berichthash, een mask generation function met zijn eigen hash en een saltlengte, en RFC 8017 §A.2.3 laat alle drie aan de ondertekenaar. De AlgorithmIdentifier draagt de keuzes van de ondertekenaar, en de verifier moet ze exact reproduceren voordat EMSA-PSS-VERIFY kan draaien

Een blok dat de verifier niet kan parsen beëindigt de verificatie voordat er ook maar een modulaire exponentiatie plaatsvindt. Een blok dat hij anders parst is erger. RFC 4055 §3.1 geeft saltLength een default van 20, dus een decoder die het saltveld nooit vindt draait EMSA-PSS-VERIFY met een salt van 20 bytes tegen een handtekening berekend met 32. Het resultaat wordt als slechte handtekening gemeld, zonder iets dat naar de metadata wijst. Hoe streng de verifier is, bepaalt welke van de twee mislukkingen u ziet, en geen enkele foutmelding noemt de AlgorithmIdentifier

De vier velden van RSASSA-PSS-params en hun tags

RFC 4055 §3.1 definieert RSASSA-PSS-params als een SEQUENCE van vier velden, elk met een expliciete context-specifieke tag en een DEFAULT-waarde. In DER wikkelt expliciet taggen elk veld in een geconstrueerde context-TLV — A0 tot A3 — met de universele codering van het veld zelf erin genest:

VeldTag (DER-byte)DEFAULTWaarde ETSI TS 119 312 §7Gecodeerd?
hashAlgorithm[0] (A0)sha1SHA-256Ja
maskGenAlgorithm[1] (A1)mgf1SHA1MGF1 met SHA-256Ja
saltLength[2] (A2)2032 (de digest-lengte)Ja
trailerField[3] (A3)trailerFieldBC (1)1Nee, hij is gelijk aan de default

De tags staan er omdat elk veld via zijn default optioneel is. hashAlgorithm en maskGenAlgorithm zijn allebei AlgorithmIdentifier-SEQUENCEs; zonder tag kon een decoder die naar een parametersblok kijkt dat één SEQUENCE bevat niet zien welke van de twee het was. Met tags noemt het tagnummer het veld, welke buren de encoder ook heeft weggelaten

PDFium Component-diagram van RSASSA-PSS-params volgens RFC 4055: hashAlgorithm A0, maskGenAlgorithm A1 en saltLength A2 dragen expliciete contexttags met DEFAULT-waarden, het ETSI-profiel emitteert SHA-256, MGF1 met SHA-256 en salt 32, en trailerField A3 is gelijk aan trailerFieldBC dus DER laat hem helemaal weg
Elk veld is getagd juist omdat elk veld via zijn default optioneel is, dus het tagnummer identificeert het veld welke buren de encoder ook weglaat

Kies een salt gelijk aan de digest-lengte en houd hem daar. Sommige API's bieden "maximum salt" of "salt length auto" aan, wat een waarde oplevert die van de sleutelgrootte afhangt; dat is legaal onder RFC 8017, maar de AlgorithmIdentifier moet dan een getal noemen dat van sleutel tot sleutel verandert, en een salt van 32 bytes bij SHA-256 is toch al wat ETSI TS 119 312 §7 vraagt. De ingebouwde ondertekenbackends van PDFium Component zetten hem allemaal vast op 32 en geven hetzelfde getal door aan de platformaanroep: cbSalt in de BCRYPT_PSS_PADDING_INFO die op Windows naar NCryptSignHash gaat, sLen in CK_RSA_PKCS_PSS_PARAMS voor een PKCS#11-token, EVP_PKEY_CTX_set_rsa_pss_saltlen onder OpenSSL, en het digest-lengte-salt dat kSecKeyAlgorithmRSASignatureDigestPSSSHA256 op macOS impliceert

GetSignatureAlgorithmParams schrijven voor uw eigen ICmsSigner

In PDFium Component implementeert elke ondertekenbackend de interface ICmsSigner uit FPdfCms.pas, en het parametersblok komt uit één methode, GetSignatureAlgorithmParams. De CMS-builder paart zijn resultaat met de OID uit GetSignatureAlgorithmOid via TDerWriter.AlgIdWithParams. U ontmoet deze methode wanneer u een eigen ondertekenaar inplugt via ConfigurePadesSignerProvider — een netwerk-HSM, een signing service, een smartcard-driver die de ingebouwde backends niet dekken. De codering die de meeste mensen eerst schrijven tagt de twee AlgorithmIdentifiers, want die moeten duidelijk worden ingepakt, en laat de twee integers kaal:

// Een veel voorkomende eerste poging: [0] en [1] getagd, [2] en [3] kaal
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),      // kale INTEGER waar [2] EXPLICIT vereist is
  W.IntegerOf(1)));     // trailerField gelijk aan DEFAULT: moet afwezig zijn

Loop hem door zoals een decoder dat doet. Hij ziet A0 en leest de hash, ziet A1 en leest de mask generation function, en stuit dan op 02 01 20, een universele INTEGER. RSASSA-PSS-params heeft nergens een ongetagde INTEGER-member, dus een strenge decoder stopt. Een soepelen slaat het onbekende element over, vindt nooit een A2, wijst de default-salt van 20 toe, en struikelt dan over een tweede verdwaalde INTEGER, 02 01 01. Geen van beide paden eindigt op 32

Diagram van de RSASSA-PSS-params-tagvalkuil: na A0 en A1 stuit een verkeerd getagd blok op een kale 02 01 20 waar de A2-expliciete tag thuishoort, een strenge decoder stopt en een soepelen valt terug op de default-salt van 20 bytes, terwijl de correcte codering het salt van 32 bytes in A2 wikkelt
RSASSA-PSS-params heeft geen ongetagde INTEGER-member, dus verdwaalde bytes daar zijn of een parsefaling of een stille terugval op de default-saltlengte, en geen van beide paden bereikt de 32 van de ondertekenaar

De conforme versie gebruikt drie aanroepen van TDerWriter.ContextSpecific uit FPdfAsn1.pas, één per niet-default-veld, elk met Constructed op True zodat het resultaat een expliciete wrapper is. Er is geen regel voor het trailer-veld. Dit is hetzelfde blok dat de Windows CNG-, macOS Keychain-, PKCS#11- en OpenSSL-backends emitteren, hier weggeschreven zoals een custom ondertekenaar hem zou dragen:

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;

// Eigen class; de andere vier methoden praten met uw HSM en zijn niet getoond
function TMyHsmSigner.GetSignatureAlgorithmParams: TBytes;
var
  W: TDerWriter;
begin
  if not FUsePss then
    Exit(nil);   // v1.5 en ECDSA: AlgIdWithParams schrijft NULL voor nil
  W := TDerWriter.Create;
  try
    // RFC 4055 3.1: [0] hash, [1] MGF1 met zijn hash, [2] salt.
    // [3] trailerField = 1 is gelijk aan zijn DEFAULT, dus X.690 11.5 laat hem weg
    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 schrijft een AlgorithmIdentifier met NULL-parameters, wat RFC 4055 §2.1 encoders opdraagt te genereren voor de geneste SHA-256-identifiers. De resulterende bytes zijn kort genoeg om op het oog te checken: een buitenste 30 34, dan A0 0F rond de AlgorithmIdentifier van SHA-256 in 15 bytes, A1 1C rond de AlgorithmIdentifier van MGF1 in 28 bytes, en A2 03 02 01 20 voor de salt. De salt die u in GetSignatureAlgorithmParams opschrijft moet matchen met degene die uw HSM werkelijk gebruikt; als de service alleen "PSS met SHA-256" blootlegt en u de saltlengte niet vertelt, zoek die uit voordat u levert, want de AlgorithmIdentifier is een claim daarover

Waarom wordt trailerField weggelaten in plaats van als [3] getagd?

X.690 §11.5 zegt dat een DER-encoder geen component mag coderen waarvan de waarde gelijk is aan zijn DEFAULT. trailerField heeft DEFAULT trailerFieldBC, het getal 1, en PSS zoals RFC 8017 hem definieert gebruikt alleen die trailer. Dus de voor de hand liggende reparatie van de eerste poging — W.IntegerOf(1) vervangen door W.ContextSpecific(3, W.IntegerOf(1), True) — produceert A3 03 02 01 01, wat een toegeeflijke BER-decoder accepteert en een strenge DER-decoder met recht mag afwijzen. De waarde klopt. Zijn aanwezigheid niet

Dezelfde regel verklaart waarom de andere drie velden er wel staan: SHA-256 is niet sha1, MGF1 met SHA-256 is niet mgf1SHA1, en 32 is niet 20. Een ondertekenaar met SHA-1 en een salt van 20 bytes zou het hele blok samenvouwen tot een lege SEQUENCE, 30 00. Die vorm verstikt iedereen die aanneemt dat "geen parameters" altijd als 05 00 wordt gespeld; voor id-RSASSA-PSS in CMS is het parametersveld verplicht, een lege SEQUENCE betekent "alle defaults", en NULL is geen geldige RSASSA-PSS-params-waarde

PDFium Component-diagram van de regel uit X.690 11.5 in de PSS-parameters: trailerField gelijk aan zijn DEFAULT trailerFieldBC moet afwezig blijven want een getagde A3-wrapper is de codering die strenge DER afwijst, terwijl saltLength 32 verschilt van de default 20 en als A2 aanwezig moet zijn
DER bestaat opdat één waarde precies één codering heeft, en een component die gelijk is aan zijn default heeft al de kortste codering die er is, namelijk helemaal niet verschijnen

BER staat een encoder toe een component met defaultwaarde mee te nemen; DER verbiedt het, want DER bestaat opdat één waarde precies één codering heeft, en een handtekening over een structuur met twee legale coderingen is te betwisten. Het parametersblok kan hier niet onder DER uit. Het zit in de buitenste signatureAlgorithm van de SignerInfo, en het wordt via het attribuut cmsAlgorithmProtection uit RFC 6211 in de signed attributes gekopieerd, waar elke byte door de handtekening wordt gedekt

Een CMS controleren die u niet zelf heeft gebouwd

Die kopie binnenin cmsAlgorithmProtection vangt een verkeerd getagd blok niet. Het attribuut en de buitenste signatureAlgorithm komen uit dezelfde aanroep, dus ze matchen altijd, en een vergelijking van de structuur met zichzelf slaagt. Wat het probleem vangt is een decoder die het parametersveld veld voor veld leest. PDFium Component's InspectPadesSignatureAlgorithm in FPdfPades.pas doet dat: hij accepteert alleen A0 tot A3, elk hooguit één keer en in oplopende volgorde, vult de RFC 4055-defaults in voor alles wat ontbreekt, en meldt de effectieve waarden in TPadesRsaPssParameters. Een kale INTEGER in het blok laat SignaturePss.Valid op False staan, en EvaluatePadesSignatureAlgorithm zet PolicyStatus dan op pcsInvalid. Hij wijst ook een PSS-hash af die verschilt van het digest-algoritme van de SignerInfo, precies wat een soepele terugval op sha1 zou opleveren

Dit doet er het meest toe bij remote signing, waarbij een externe service een kant-en-klare CMS teruggeeft en CompletePadesRemoteSignature haar in de voorbereide PDF inbedt. Controleer de blob voordat u haar inbedt. Haar daar afwijzen kost één API-aanroep; erachter komen via de validator van een klant kost een herondertekenronde met wie de service ook draait:

uses
  System.SysUtils, FPdfPades;

// Voorgestelde check die u zelf toevoegt vóór 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: laat de inspector het ondertekenaarscertificaat in de CMS vinden
  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;
    // Effectieve waarden, defaults inbegrepen: een ontbrekende A2 duikt hier op als 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 houdt nog de gedecodeerde waarden voor het log vast
  Values := Why;
  Result := VerifyCmsAlgorithmProtection(CmsDer, Why);
  if Result then
    Why := Values;   // de protectioncheck wist Why bij succes
end;

Log de Why-string zelfs bij succes. Een saltlengte van 20 naast een SHA-256-hash is het vingerafdruk van een parametersblok waarvan de A2 verdwenen is, wat de eigen logs van de service ook zeggen. Voor een snelle blik zonder code print openssl asn1parse -inform DER -in signature.p7s de boom; onder de id-RSASSA-PSS-OID wilt u cont [ 2 ] zien die een INTEGER van 20 hex omwikkelt, en helemaal geen cont [ 3 ]. Een INTEGER op topniveau binnenin die SEQUENCE is de verkeerd getagde codering uit het eerste codeblok

Waar dit past in de rest van het PSS-verhaal

Parametercodering is een van drie aparte manieren waarop PSS in een PAdES-handtekening kan misgaan, en ze uit elkaar houden verkort het debuggen. Op macOS biedt een bepaalde sleutel of een ouder systeem PSS misschien niet aan, en de backend ondertekent dan met PKCS#1 v1.5 en declareert sha256WithRSAEncryption om dat te matchen; die capaciteitsvraag staat in PAdES ondertekenen met een macOS Keychain-identiteit. Een PKCS#11-token kan CK_RSA_PKCS_PSS_PARAMS lezen met een andere integerbreedte dan de aanroeper opschreef, een ABI-vraag die wordt behandeld in CK_ULONG en de PKCS#11-packingvalkuil. De derde is die hierboven: de sleutel is willig, het token berekent de juiste bytes, en de DER die ze beschrijft volgt RFC 4055 §3.1 niet

De signed-attributes-SET heeft zijn eigen versie van de zelfconsistentie-blindvlek, waarbij een SET in één volgorde gehasht en in een andere geëmitteerd er prima uitziet totdat een vreemde verifier de hash herberekent; zie CMS signedAttrs en DER SET OF-sortering. Voor de prepare, sign remotely, complete-cyclus waarin de check hierboven past, behandelt remote PAdES signing met een HSM het faseringprotocol en wat de afrondingsstap weigert

De PDFium Delphi component levert zijn ondertekenbackends als broncode, dus de GetSignatureAlgorithmParams-hoofden in de Windows-, macOS-, PKCS#11- en OpenSSL-units staan er om te lezen en te dumpen naast de output van uw eigen ondertekenaar, byte voor byte