Articol tehnic

Parsare semnături CMS în Delphi: limite DER și arce OID

PDF Library for Delphi (PDFlibPas) extrage certificatele din interiorul unei semnături PDF cu o parcurgere DER pură peste CMS-ul SignedData stocat în /Contents, fără să implice deloc CryptoAPI. Din v3.539.10, fiecare citire imbricată e mărginită de elementul ei părinte, padding-ul de zeruri de după CMS e tăiat la lungimea declarată de CMS, iar identificatorii de obiect își codează primul subidentificator combinat în base-128. Regula limitelor și corecția OID au înlocuit amândouă cod care dădea răspunsuri greșite fără să ridice vreo eroare, iar regula padding-ului împiedică cititorul mai strict să respingă semnături reale

Partea de citire contează mai mult decât pare. Unelte de validare pe termen lung trebuie să scoată certificatul semnatarului și emitenții lui dintr-o semnătură existentă înainte să poată aduce datele de revocare, un raport de audit trebuie să spună cine a semnat, iar un build Lazarus pe Linux nu are funcțiile de mesaje Windows la care să se sprijine. Un parser în poziția asta nu crapă prea des pe input rău. Eșecul care doare arată așa: un contor de certificate care include octeții unui vecin, o potrivire de semnatar făcută pe câmpul greșit ori un OID care devine în tăcere un alt OID. Un pipeline de semnături construit peste asta raportează nonsens cu toată încrederea

Citirea certificatelor semnatarului dintr-un PDF semnat

Cinci metode TPDFlib acoperă partea de citire, și toate primesc InputFile, Password, FieldName: fiecare apel deschide fișierul read-only, răspunde și îl închide la loc. GetSignatureEmbeddedCertificateCount și GetSignatureEmbeddedCertificateDER enumără setul de certificate în ordinea codării, GetSignatureSignerCertificateDER întoarce certificatul care a produs un anumit SignerInfo, iar GetSignatureCertificateChainLength / GetSignatureCertificateChainDER urcă de la semnatar spre cel mai îndepărtat emitent pe care semnătura îl cară cu ea. Indexele pornesc de la zero. Păstrați rezultatele în AnsiString, tocmai de-asta le întoarce biblioteca așa: un blob DER trecut prin string sau printr-un TStrings trece printr-o conversie de set de caractere și revine corupt

uses
  SysUtils, Classes, PDFlibrary;

procedure SaveDer(const FileName: string; const Der: AnsiString);
var
  Fs: TFileStream;
begin
  Fs := TFileStream.Create(FileName, fmCreate);
  try
    if Der <> '' then
      Fs.WriteBuffer(Der[1], Length(Der));
  finally
    Fs.Free;
  end;
end;

const
  Src = 'contract-signed.pdf';
  Field = 'Signature1';
var
  Pdf: TPDFlib;
  ChainLen, I: Integer;
  SignerDer, LastDer: AnsiString;
begin
  Pdf := TPDFlib.Create;
  try
    WriteLn('Certificates in the CMS: ',
      Pdf.GetSignatureEmbeddedCertificateCount(Src, '', Field));
    SignerDer := Pdf.GetSignatureSignerCertificateDER(Src, '', Field, 0);
    if SignerDer = '' then
      raise Exception.Create('signer certificate missing or not matched');
    SaveDer('signer.cer', SignerDer);

    ChainLen := Pdf.GetSignatureCertificateChainLength(Src, '', Field, 0);
    for I := 0 to ChainLen - 1 do
      SaveDer(Format('chain-%d.cer', [I]),
        Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, I));
    if ChainLen > 0 then
    begin
      LastDer := Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, ChainLen - 1);
      WriteLn('Next issuer, if any: ', Pdf.GetCertificateIssuerURLs(LastDer));
    end;
  finally
    Pdf.Free;
  end;
end;

Două lucruri din acel output cer atenție. Un contor de 0 nu e un diagnostic: un câmp lipsă, o parolă greșită, un blob care nu e DER și un SignedData care pur și simplu omite setul opțional de certificate revin toate ca 0 sau ca șir gol, deci logați numele câmpului lângă număr. Iar un lanț care se termină înaintea unui certificat auto-emis nu e nici el o eroare. Constructorul de lanț folosește doar certificatele încorporate în semnătură, deci emitenții rămași trebuie aduși prin adresele pe care le raportează GetCertificateIssuerURLs

Cât din /Contents e de fapt CMS-ul?

Doar prefixul pe care îl declară SEQUENCE-ul exterior aparține CMS-ului, iar PLTrimCMSPadding taie tot ce urmează după el. Un semnatar rezervă șirul hex /Contents înainte ca CMS-ul să existe, pentru că /ByteRange-ul descris în ISO 32000-1 §12.8.1 trebuie fixat primul, deci slotul e dimensionat cu generozitate, iar coada nefolosită e zeruri. PLTrimCMSPadding citește primul TLV, cere tag-ul $30 și întoarce octeții până la sfârșitul acelui element; orice nu începe cu un SEQUENCE bine format revine gol. Nivelul de sus e singurul loc unde octeții de la coadă sunt legali, iar distincția contează pentru secțiunea următoare: o regulă strictă de tipul „elementul trebuie să consume tot bufferul” ar respinge fiecare semnătură reală, în vreme ce o regulă laxă aplicată la fiecare adâncime lasă câmpurile imbricate să citească octeți care nu le aparțin

PDFlibPas PLTrimCMSPadding citește primul TLV al șirului hex /Contents rezervat, cere tag-ul $30 și taie padding-ul de zeruri la lungimea declarată de SEQUENCE-ul exterior, întorcând un rezultat gol când bufferul nu începe cu un SEQUENCE bine format
Octeții de la coadă sunt legali doar la nivelul de sus, unde slotul rezervat trebuie să rămână fix pentru /ByteRange — citirile mai adânci primesc în schimb regula mărginirii de părinte

De ce are nevoie un cititor DER de offset-ul de final al părintelui?

Un element imbricat e valid doar dacă se termină în interiorul părintelui lui, iar verificarea față de sfârșitul bufferului nu demonstrează asta. DERReadTLV-ul de nivel jos din PDFlibASN1 mărginește fiecare element față de întregul șir, ceea ce e verificarea corectă pentru obiectul cel mai exterior și cea greșită pentru tot ce e sub el. Imaginați-vă un SignerInfo al cărui issuerAndSerialNumber declară 40 de octeți, în vreme ce issuer Name-ul din interiorul lui pretinde 60. Fiecare octet e în continuare în buffer, deci un cititor mărginit de buffer acceptă Name-ul, citește numărul de serie din algoritmul digest care urmează și apoi compară perechea asta cu certificatele încorporate. Înainte de v3.539.10, walker-ul CMS citea exact așa. Corecția e un wrapper mic care poartă poziția de final a părintelui în fiecare citire

PDFlibPas mărginește fiecare citire DER imbricată la elementul părinte: un issuer Name de 60 de octeți în interiorul unui issuerAndSerialNumber de 40 de octeți e acceptat de vechiul DERReadTLV mărginit de buffer, care apoi citește numărul de serie din digestAlgorithm, în timp ce ReadTLVWithin refuză orice element care se termină peste ParentEnd
În interiorul bufferului e memory safety, în interiorul părintelui e corectitudine — PDFlibPas căra offset-ul de final al părintelui prin fiecare nivel CMS, ca o lungime ostilă să nu poată împrumuta octeții unui vecin
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // nimic rămas în interiorul părintelui: refuză să pornească o citire
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // Offset stă acum cu unul peste element; nu are voie să treacă de părinte
  Result := Offset <= ParentEnd;
end;

// fiecare nivel își înregistrează propriul capăt și îl pasează mai jos:
//   OuterEnd    := end of ContentInfo         (RFC 5652 section 3)
//   ExplicitEnd := end of content [0] EXPLICIT
//   ContentEnd  := end of SignedData          (RFC 5652 section 5.1)
//   SignerEnd / InnerEnd for SignerInfo and issuerAndSerialNumber

Unitatea, PDFlibCMSRead, căra acum aceste capete prin ContentInfo, wrapper-ul [0] EXPLICIT, câmpurile SignedData până la signerInfos, SignerIdentifier în ambele forme ale lui, issuerAndSerialNumber și [0] subjectKeyIdentifier (RFC 5652 §5.3), și câmpurile tbsCertificate citite din fiecare certificat încorporat la potrivirea semnatarului. În interiorul setului de certificate și al setului signerInfos, un element care trece de sfârșitul setului oprește bucla: PLExtractCMSCertificates întoarce certificatele pe care le acceptase deja și nu lipește niciodată octeții crls sau signerInfos care urmează de ultimul. Potrivirea issuer-and-serial cere și ea ambele jumătăți, pentru că un număr de serie e unic doar în interiorul unui singur emitent

De ce a ieșit 2.999.3 ca 1.15.3?

Primele două arce ale unui OID se combină într-un singur subidentificator, nu într-un singur octet, iar subidentificatorul acela e codificat base-128 ca orice alt arc. X.690 §8.19.4 îl definește ca 40 * arc1 + arc2; vechiul DER_OID scria valoarea asta cu Byte(...), corect doar până la 127, valoarea lui 2.47. Pentru 2.999 suma e 1079, cast-ul la byte păstrează 55, iar 55 decodează ca 1.15, deci identificatorul numește în tăcere o altă ramură a arborelui. Valorile de la 128 la 255 eșuează altfel, emițând un octet cu bitul de continuare setat care înghite arcul următor. Majoritatea identificatorilor PKI (1.2.840..., 2.5.29..., 0.4.0...) nu ajung niciodată la limită, de-asta a supraviețuit bug-ul; arcele joint-iso-itu-t de la 2.48 în sus ajung. DER_OID deservește atât encoder-ul pentru signed attributes, cât și matcher-ul din DERFindExtensionByOID și verificarea content-type-ului SignedData, deci o codificare greșită rupea atât scrierea, cât și căutarea

uses
  SysUtils, PDFlibASN1;

function Hex(const S: AnsiString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + IntToHex(Byte(S[I]), 2) + ' ';
  Result := Trim(Result);
end;

begin
  WriteLn(Hex(DER_OID('2.999.3')));               // 06 03 88 37 03
  WriteLn(Hex(DER_OID('2.47.1')));                // 06 02 7F 01
  WriteLn(Hex(DER_OID('2.48.1')));                // 06 03 81 00 01
  WriteLn(Hex(DER_OID('2.5.29.14')));             // 06 03 55 1D 0E
  WriteLn(Hex(DER_OID('1.2.840.113549.1.7.2')));  // 06 09 2A 86 48 86 F7 0D 01 07 02
end.
PDFlibPas DER_OID combină primele două arce OID ca 40 * arc1 + arc2 și codifică base-128 suma într-un UInt64, deci 2.999.3 devine 06 03 88 37 03, în timp ce vechiul cast Byte păstra 55 și decodea în tăcere identificatorul ca 1.15.3
Majoritatea arcelor PKI nu ajung niciodată la limită, de-asta a supraviețuit bug-ul — arcele joint-iso-itu-t de la 2.48 în sus au nevoie de doi octeți, iar testul ține 2.47 și 2.48 de o parte și de alta a ei

Valoarea combinată e ținută într-un UInt64 intenționat. DER_OID parsează arcele în Int64, deci un al doilea arc legal poate fi cât Int64.MaxValue, iar adunarea a 80 pentru arc1 = 2 face overflow pe un întreg pe 64 de biți cu semn. UInt64 duce Int64.MaxValue + 80 fără wrapping, iar bufferul de lucru de zece octeți ține cele zece grupuri de 7 biți de care are nevoie o valoare pe 64 de biți. Vectorii de test care merită păstrați sunt cei de o parte și de alta a limitei: 2.47 trebuie să rămână un singur octet, iar 2.48 trebuie să devină doi

Ce garantează walker-ul CMS pe partea de citire?

PDFlibCMSRead garantează structura și nimic mai mult: întoarce octeții care stau acolo unde RFC 5652 spune că ar trebui să stea și nu verifică nicio semnătură, niciun digest, nicio perioadă de validitate. Walker-ul acceptă doar DER, deci DERReadTLV respinge lungimile nedeterminate și numerele de tag pe mai mulți octeți, iar un CMS codificat BER de la un semnatar neconform raportează zero certificate în loc de o ghicire parțială. Certificatele de atribute și celelalte alternative CertificateChoices sunt omise pentru că nimic din aval nu le poate folosi. Verificarea criptografică rămâne la codul care o deține, și anume pornește cu verificările de acoperire de octeți descrise în semnarea PAdES și validarea ByteRange în Delphi și continuă cu clasificarea a ceea ce s-a schimbat după semnarea unui PDF

Lecția mai largă se transferă oricărui format binar: „în interiorul bufferului” e o proprietate de memory safety, „în interiorul părintelui” e o proprietate de corectitudine, iar un parser are nevoie de ambele. Același mod de a gândi lungimile ostile străbate și întărirea unui parser PDF în Pascal împotriva fișierelor malitioase. Extracția certificatelor, construirea lanțului și API-urile de validare pe termen lung discutate aici se livrează cu losLab PDF Library for Delphi, pentru Delphi, C++Builder și Lazarus