Articol tehnic

Verificare semnătură PDF ECDSA în Delphi: DER la P1363

Un signatureValue ECDSA dintr-un container CMS este un DER SEQUENCE { INTEGER r, INTEGER s }. Funcția Windows CNG BCryptVerifySignature nu acceptă niciuna dintre acestea: vrea forma IEEE P1363 cu lățime fixă r || s, fără tag-uri și fără lungimi. HotPDF, componenta VCL nativă pentru PDF în Delphi și C++Builder, convertește între cele două forme, sub reguli DER stricte, înainte de a importa o cheie

Defecțiunea pe care o previne asta e una specifică și demoralizantă. Acrobat deschide documentul și arată un bifat verde. Propriul tău verificator, parcurgând aceiași bytes, returnează invalid, sau CNG întoarce STATUS_INVALID_SIGNATURE fără nicio altă explicație. Nimic nu e în neregulă cu semnătura. Ce e în neregulă e că aproximativ șaptezeci de bytes de ASN.1 au fost predați unui API care aștepta șaizeci și patru de bytes de întreg brut, iar nepotrivirea e invizibilă dacă nu știi să o cauți

De ce respinge BCryptVerifySignature o semnătură ECDSA validă?

Pentru că cele două părți ale apelului vorbesc encodări de semnătură diferite și niciuna nu anunță asta. ISO 32000-1 §12.8 spune că un dicționar de semnătură poartă un blob CMS în /Contents; RFC 5652 §5.3 spune că signatureValue din fiecare SignerInfo este un OCTET STRING al cărui conținut e orice definește algoritmul de semnătură. Pentru ECDSA, acel conținut e structura DER SEC 1: un SEQUENCE care ține două INTEGER. Are lungime variabilă prin design, pentru că r și s sunt întregi, iar DER elimină octeții zero de la început din întregi

IEEE P1363 ia poziția opusă. Definește semnătura ca fiind concatenarea celor două coordonate, fiecare umplută la stânga cu zerouri până la exact lățimea în bytes a câmpului curbei. O semnătură P-256 are mereu 64 de bytes. O encodare DER a aceleiași semnături are în mod normal 70 sau 71 de bytes și poate varia de la aproximativ 8 până la 72. Dă forma DER lui BCryptVerifySignature și doar verificarea lungimii condamnă apelul, motiv pentru care HotPDF normalizează înainte de verificare, nu după

uses
  HPDFECDSA;

// Converts a CMS signatureValue into the fixed-width form CNG expects.
// ARaw comes back as 64 bytes for P-256, 96 for P-384, 132 for P-521.
function ToP1363(const ADerSig: TBytes; ACurve: THPDFECDSACurve;
  out ARaw: TBytes): Boolean;
begin
  Result := HPDFECDSANormalizeSignature(ADerSig, ACurve, eseDER, ARaw);
end;

Regulile DER pe care un parser de semnătură nu are voie să le relaxeze

Fiecare respingere listată aici e o respingere pe care HotPDF o execută deliberat, iar fiecare închide o cale pe care un parser îngăduitor ar lăsa-o deschisă. Tentația, atunci când scrii un convertor, e să găsești cele două noduri INTEGER, să le copiezi conținutul și să mergi mai departe. Asta funcționează pe input bine format și acceptă tacit o familie de re-encodări maleabile pe input ostil. Așa că HPDFECDSANormalizeSignature respinge un întreg negativ, adică orice r sau s al cărui prim octet de conținut are bitul de sus setat, pentru că un scalar ECDSA valid e pozitiv. Respinge o valoare care e în întregime zero, pentru că r = 0 sau s = 0 nu e niciodată o semnătură legitimă. Respinge un octet zero redundant la început: X.690 §8.3 permite exact unul, și doar atunci când următorul octet ar fi citit altfel ca negativ, așa că un 00 urmat de un octet sub 0x80 e o re-encodare, nu o semnătură. Respinge un header de lungime neminimal, pentru că X.690 §10.1 cere forma definită encodată în cei mai puțini octeți posibili, iar o lungime în formă lungă care ar fi putut fi în formă scurtă e un șir de bytes diferit care poartă același sens. Respinge un întreg mai lat decât dimensiunea coordonatei curbei, pentru că acea valoare nu poate fi un element de câmp. Și respinge orice nod rămas după s, împreună cu un SEQUENCE exterior a cărui lungime totală nu e egală cu lungimea întregului blob

Ultimele două contează mai mult decât par. Bytes rămași după SEQUENCE sunt trucul clasic de maleabilitate a semnăturii: adaugi gunoi la coadă, iar un verificator îngăduitor tot spune valid, în timp ce șirul de bytes pe care l-a validat nu e șirul de bytes care a fost semnat. Același instinct stă la baza întăririi lungimii ASN.1 descrisă în nota despre parsarea PKCS#12, și e același instinct și aici. Într-o cale de verificare, o structură acceptată care nu a fost niciodată emisă de un semnatar conform e un defect, nu o curtoazie

Lățimea coordonatei aparține curbei, nu semnăturii

HotPDF derivă lățimea de ieșire din OID-ul curbei numite, niciodată din lungimea DER-ului pe care tocmai l-a parsat. Aceasta e a doua jumătate a conversiei, și jumătatea unde e ușor să greșești subtil. RFC 5480 §2.1.1 identifică curba în parametrii SubjectPublicKeyInfo ai certificatului, iar HPDFECDSACurveFromOID mapează cele trei OID-uri pe care le suportă HotPDF: 1.2.840.10045.3.1.7 pentru P-256, 1.3.132.0.34 pentru P-384 și 1.3.132.0.35 pentru P-521. HPDFECDSACoordinateSize returnează apoi 32, 48 sau 66 de bytes, iar bufferul P1363 e dublul acelei valori: 64, 96 sau 132. Fiecare întreg decodat e aliniat la dreapta în jumătatea lui, așa că un r scurt e umplut cu zerouri la stânga, nu deplasat. P-521 e cel care prinde oamenii pe picior greșit, pentru că 521 de biți înseamnă 65,125 bytes și se rotunjește la 66, dând o semnătură de 132 de bytes pe care nicio intuiție bazată pe puteri ale lui doi n-ar fi prezis-o. Cheia publică călătorește alături ca punct EC necomprimat, conform RFC 5480 §2.2, adică 0x04 urmat de X și Y, așa că HotPDF verifică dacă are exact 1 + 2 * CoordinateSize bytes și începe cu 0x04 înainte de a atinge CNG

var
  Digest, SigDER, PublicPoint: TBytes;
  Curve: THPDFECDSACurve;
  Res: THPDFECDSAVerifyResult;
begin
  // secp256r1, taken from the certificate SubjectPublicKeyInfo parameters
  Curve := HPDFECDSACurveFromOID('1.2.840.10045.3.1.7');

  // PublicPoint must be $04 || X || Y, so 1 + 2 * 32 = 65 bytes for P-256
  Res := HPDFECDSAVerifyDigest(Digest, SigDER, PublicPoint, Curve, eseDER);

  case Res of
    evrValid:
      Memo1.Lines.Add('signature verifies');
    evrInvalid:
      Memo1.Lines.Add('signature does not match the digest');
    evrMalformed:
      Memo1.Lines.Add('DER encoding or public point rejected');
    evrUnsupported:
      Memo1.Lines.Add('curve or algorithm not supported here');
    evrProviderUnavailable:
      Memo1.Lines.Add('bcrypt.dll or the curve provider is missing');
    evrProviderError:
      Memo1.Lines.Add('CNG returned an unexpected status');
  end;
end;

Observă ultimul parametru. HPDFECDSAVerifyDigest acceptă și eseP1363 pentru apelanții care dețin deja o semnătură cu lățime fixă, de la un token hardware sau de la un serviciu de semnare la distanță care returnează r || s brut. Acea cale tot impune verificarea lungimii și verificarea non-zero pe ambele jumătăți, așa că un buffer de dimensiunea corectă, plin de zerouri, e refuzat, nu trecut mai departe la provider

De ce eșuează numele generic de algoritm ECDSA pe Windows mai vechi?

Pentru că numele generic e mai nou decât baza de deployment în care livrezi. CNG expune un identificator de algoritm ECDSA care deduce curba din cheia importată, și e modul curat de a scrie acest cod, dar BCryptOpenAlgorithmProvider e garantat să-l rezolve doar pe versiuni de Windows mai recente. Pe o mașină mai veche, apelul de deschidere eșuează, handle-ul de provider rămâne nil, iar fiecare verificare ECDSA din aplicația ta raportează unsupported pe o semnătură care e perfect bună. HotPDF evită prăpastia deschizând în schimb identificatorii per-curbă. Rezolvă ECDSA_P256, ECDSA_P384 și ECDSA_P521 o singură dată, pune în cache câte un handle de provider per curbă și le închide la finalizarea unit-ului. Fiecare verificare face apoi doar munca ieftină: importă o cheie publică temporară dintr-un ECCPUBLICBLOB, apelează BCryptVerifySignature, distruge cheia. Fără LoadLibrary repetat, fără GetProcAddress repetat, fără deschidere și închidere de provider la fiecare semnătură. Verificarea în lot a câteva sute de documente simte diferența, la fel și un proces de serviciu care altfel ar tot crea și distruge handle-uri de provider sub sarcină

Codurile de rezultat rămân oneste despre distincție. evrProviderUnavailable înseamnă că mașina n-a putut da HotPDF un provider; evrInvalid înseamnă că CNG a răspuns STATUS_INVALID_SIGNATURE. Comprimarea acestor două într-un singur eșec e felul în care o problemă de deployment ajunge raportată greșit ca document falsificat. Aceeași separare între eșecul de mediu și eșecul criptografic străbate și tratarea CNG și CAPI de pe partea de semnare, acoperită în articolul despre semnarea din certificate store și ordinea de bytes

Care certificat a semnat asta? SignerIdentifier înseamnă două lucruri diferite

RFC 5652 §5.3 face din SignerIdentifier un CHOICE, iar un verificator care tratează doar o ramură va verifica silențios contra cheii greșite. Prima ramură e issuerAndSerialNumber, un SEQUENCE care ține Name-ul emitentului în DER brut și INTEGER-ul de serie, iar potrivirea lui e o comparație de bytes contra fiecărui certificat din mulțimea certificates a CMS-ului. A doua ramură e [0] subjectKeyIdentifier, un OCTET STRING cu tag implicit, iar potrivirea lui cere să sapi în certificat, nu doar să-i compari câmpurile de header

Săpătura are un nivel care surprinde lumea. Identificatorul cheii trăiește într-o extensie X.509v3, așa că HotPDF parcurge câmpul de extensii [3] al tbsCertificate, găsește extensia al cărei OID e 2.5.29.14, sare peste BOOLEAN-ul opțional critical și ia OCTET STRING-ul extnValue. Acel octet string nu e identificatorul. Conform RFC 5280 §4.2.1.2, conținutul lui e el însuși DER, iar tipul KeyIdentifier e un alt OCTET STRING, așa că parsezi a doua oară ca să ajungi la bytes-ii reali. Te oprești un nivel mai devreme și compari un wrapper de 22 de bytes cu un identificator de 20 de bytes, niciun certificat nu se potrivește vreodată, iar verificatorul cade înapoi pe orice euristică ai scris-o în continuare, ceea ce e adevăratul pericol. Luarea primului certificat din mulțime e o scurtătură tentantă, și e greșită oricând CMS-ul poartă un lanț, ceea ce se întâmplă de cele mai multe ori, pentru că frunza nu e obligată să vină prima. HotPDF acceptă un certificat nepotrivit doar când containerul deține exact unul; cu mai multe certificate prezente, o potrivire exactă de SignerIdentifier e obligatorie. Verificarea unui digest contra cheii publice a unei CA intermediare nu produce o eroare prietenoasă, ci un invalid ferm pe un document care e de fapt în regulă

var
  Pdf: THotPDF;
  Info: THPDFSignatureInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('signed.pdf') > 0 then
      for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
        if Pdf.VerifyLoadedSignatureEx(I, Info) = svValid then
          Memo1.Lines.Add(Format('%s: %s %s over %s, signer %s',
            [String(Info.FieldName), String(Info.PublicKeyAlgorithm),
             String(Info.CurveName), String(Info.HashAlgorithm),
             String(Info.SignerName)]))
        else
          Memo1.Lines.Add(Format('%s: not valid', [String(Info.FieldName)]));
  finally
    Pdf.Free;
  end;
end;

THPDFSignatureInfo.CurveName raportează P-256, P-384 sau P-521, astfel încât un log de audit înregistrează ce curbă a fost de fapt folosită, nu doar cuvântul ECDSA. Instalația la nivel de document din jurul acestui apel, în special cum sunt hash-uite segmentele /ByteRange și de ce digest-ul trebuie calculat peste fișier, nu peste arborele de obiecte parsat, e subiectul articolului însoțitor despre verificarea semnăturilor PDF

Ce nu îți oferă asta

Un rezultat verde de la HPDFECDSAVerifyDigest răspunde la o singură întrebare: acești bytes au fost semnați de cheia privată care se potrivește cu această cheie publică. Nu spune nimic despre dacă acea cheie aparține cuiva în care ar trebui să ai încredere. Construirea lanțului până la o ancoră de încredere, revocarea prin CRL sau OCSP și verificările de politică sunt o muncă separată, iar orice produs care raportează o semnătură validă fără ele raportează mai puțin decât presupune utilizatorul. Datele de valabilitate ale certificatului sunt afișate separat în THPDFSignatureInfo exact din acest motiv: o semnătură poate verifica criptografic în timp ce certificatul care a produs-o a expirat acum doi ani. Suportul de curbe e de asemenea deliberat îngust. Sunt tratate trei curbe prime NIST, iar o semnătură peste orice altă curbă returnează unsupported, nu o presupunere. Calea CNG e doar pentru Windows, ceea ce e compromisul corect pentru o componentă VCL, dar merită menționat înainte să planifici un serviciu cross-platform în jurul ei. Iar stricteța nu e configurabilă: nu există un mod îngăduitor care acceptă o lungime DER neminimală doar pentru că un semnatar vechi a emis una. Dacă întâlnești un asemenea fișier în producție, răspunsul onest e să-l înregistrezi și să chestionezi producătorul, nu să lărgești parser-ul până trece fișierul

Calea de verificare ECDSA descrisă aici este livrată ca parte a HotPDF Component standard, pentru Delphi și C++Builder, alături de căile RSA PKCS#1 v1.5 și RSA-PSS și de înregistrarea completă de informații despre semnătură; pagina produsului conține referința completă pentru semnătura digitală