Tehnički članak

CMS parsiranje potpisa u Delphiju: DER granice i OID lukovi

PDF Library for Delphi (PDFlibPas) izvlači certifikate unutar PDF potpisa čistim DER prolazom kroz CMS SignedData pohranjen u /Contents, bez ikakvog CryptoAPI-ja. Od v3.539.10 svako ugniježđeno čitanje ograničeno je roditeljskim elementom, padding od nula nakon CMS-a reže se na duljinu koju CMS sam deklarira, a identifikatori objekata kodiraju svoj udruženi prvi subidentifier u base-128. Pravilo granica i OID ispravak zamijenili su kod koji je davao pogrešne odgovore bez da je išta prijavio, a pravilo punjenja čuva strožeg čitača od odbijanja pravih potpisa

Strana čitanja važnija je no što izgleda. Alati za long-term validation moraju izvući certifikat potpisnika i njegove izdavatelje iz postojećeg potpisa prije nego što mogu dohvatiti podatke o opozivu, revizijsko izvješće mora reći tko je potpisao, a Lazarus build na Linuxu nema Windows message funkcija na koje bi se oslonio. Parser u tom položaju rijetko padne na lošem ulazu. Način kvara koji boli jest broj certifikata koji uključuje bajtove koje posjeduje susjed, poklapanje potpisnika s pogrešnim poljem ili OID koji tiho postane drugi OID. Potpisni pipeline sagrađen na tome javlja samouvjereno besmislice

Izvlačenje certifikata potpisnika iz potpisanog PDF-a

Pet metoda TPDFlib pokriva stranu čitanja, i sve primaju InputFile, Password, FieldName: svaki poziv otvara datoteku samo za čitanje, odgovori, pa je ponovno zatvori. GetSignatureEmbeddedCertificateCount i GetSignatureEmbeddedCertificateDER nabrajaju certifikate iz skupa u redoslijedu kodiranja, GetSignatureSignerCertificateDER vraća certifikat koji je proizveo dani SignerInfo, a GetSignatureCertificateChainLength / GetSignatureCertificateChainDER prolaze od tog potpisnika prema najdaljem izdavatelju koji sam potpis nosi. Indeksi su zero-based. Rezultate držite u AnsiString, zato ih i biblioteka tako vraća: DER blob proveden kroz string ili TStrings prolazi kroz pretvorbu znakovnog skupa i vrati se oštećen

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;

Dvije stvari u tom ispisu traže pažnju. Broj 0 nije dijagnoza: nepostojeće polje, pogrešna lozinka, blob koji nije DER i SignedData koji jednostavno izostavi neobavezni skup certifikata svi se vrate kao 0 ili prazan string, pa zapisujte ime polja uz broj. I lanac koji završi prije samopotpisanog certifikata nije ni pogreška. Graditelj lanca koristi samo certifikate ugrađene u potpis, pa preostale izdavatelje morate dohvatiti preko adresa koje prijavi GetCertificateIssuerURLs

Koliko je /Contents zapravo CMS?

Samo prefiks koji vanjski SEQUENCE deklarira pripada CMS-u, a PLTrimCMSPadding reže sve iza toga. Potpisnik rezervira /Contents hex string prije nego CMS uopće postoji, jer /ByteRange koji opisuje ISO 32000-1 §12.8.1 mora biti fiksiran prvi, pa je slot dimenzioniran velikodušno, a neiskorišteni rep su nule. PLTrimCMSPadding pročita prvi TLV, zahtijeva tag $30 i vraća bajtove do kraja tog elementa; sve što ne počinje dobro oblikovanim SEQUENCE-om vrati se prazno. Ta najviša razina jedino je mjesto gdje su zaostali bajtovi legalni, i ta razlika važi za idući odjeljak: strogo pravilo „element mora potrošiti cijeli buffer" odbilo bi svaki stvarni potpis, dok popustljivo pravilo primijenjeno na svakoj dubini dopušta ugniježđenim poljima da čitaju bajtove koji nisu njihovi

PDFlibPas PLTrimCMSPadding čita prvi TLV rezerviranog /Contents hex stringa, zahtijeva tag $30 i reže padding od nula na duljinu koju vanjski SEQUENCE deklarira, a vraća prazan rezultat kad buffer ne počinje dobro oblikovanim SEQUENCE-om
Zaostali bajtovi legalni su samo na najvišoj razini, gdje rezervirani slot mora ostati fiksiran radi /ByteRange — dublja čitanja dobivaju pravilo ograničeno roditeljem

Zašto DER čitač treba završni offset roditelja?

Ugniježđeni element valjan je samo ako završava unutar svojeg roditelja, a provjera prema kraju buffera to ne dokazuje. Niskorazinski DERReadTLV u PDFlibASN1 ograničava svaki element prema cijelom stringu, što je ispravna provjera za najvanjski objekt i pogrešna za sve ispod njega. Zamislite SignerInfo čiji issuerAndSerialNumber deklarira 40 bajtova dok issuer Name unutar njega traži 60. Svaki je bajt i dalje u bufferu, pa će čitač ograničen bufferom prihvatiti Name, pročitati serijski broj iz digest algoritma koji slijedi, a onda taj par usporediti s ugrađenim certifikatima. Prije v3.539.10 CMS walker čitao je točno tako. Ispravak je mali wrapper koji krajnju poziciju roditelja nosi u svako čitanje

PDFlibPas ograničava svako ugniježđeno DER čitanje roditeljskim elementom: 60-bajtni issuer Name unutar 40-bajtnog issuerAndSerialNumber prihvati stari DERReadTLV ograničen bufferom, koji zatim serijski broj pročita iz digestAlgorithm, dok ReadTLVWithin odbija svaki element koji završava iza ParentEnd
Unutar buffera je memory safety, unutar roditelja je ispravnost — PDFlibPas provlači završni offset roditelja kroz svaku CMS razinu da neprijateljska duljina ne može posuditi susjedove bajtove
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // u roditelju više ničeg nema: odbij pokrenuti čitanje
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // Offset sada stoji jedan iza elementa; ne smije prijeći roditelja
  Result := Offset <= ParentEnd;
end;

// svaka razina pamti svoj kraj i predaje ga dalje:
//   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

Jedinica PDFlibCMSRead sada provlači te krajeve kroz ContentInfo, [0] EXPLICIT omotnicu, polja SignedData sve do signerInfos, SignerIdentifier u objema njegovim oblicima, issuerAndSerialNumber i [0] subjectKeyIdentifier (RFC 5652 §5.3), te polja tbsCertificate pročitana iz svakog ugrađenog certifikata pri poklapanju potpisnika. Unutar skupa certifikata i skupa signerInfos element koji prijeđe kraj skupa zaustavlja petlju: PLExtractCMSCertificates vrati certifikate koje je već prihvatila i nikad ne zalijepi sljedeće bajtove crls ili signerInfos na posljednji. Poklapanje issuer-and-serial traži i obje polovice, jer je serijski broj jedinstven samo unutar jednog izdavatelja

Zašto je 2.999.3 izašao kao 1.15.3?

Prva dva luka OID-a spajaju se u jedan subidentifier, ne u jedan bajt, i taj se subidentifier kodira u base-128 kao i svaki drugi luk. X.690 §8.19.4 definira ga kao 40 * arc1 + arc2; stariji DER_OID zapisivao je tu vrijednost s Byte(...), što je točno samo do 127, vrijednosti od 2.47. Za 2.999 zbroj je 1079, pretvorba u bajt zadrži 55, a 55 se dekodira kao 1.15, pa identifier tiho imenuje drugu granu stabla. Vrijednosti od 128 do 255 padaju drugačije, emitirajući jedan bajt s postavljenim continuation bitom koji proguta sljedeći luk. Većina PKI identifikatora (1.2.840..., 2.5.29..., 0.4.0...) nikad ne dosegne granicu, zato je i preživjela; joint-iso-itu-t lukovi od 2.48 naviše dosegnu je. DER_OID služi i enkoderu za signed attributes i matchera u DERFindExtensionByOID i provjeri SignedData content-typea, pa je pogrešno kodiranje slomilo i pisanje i pretraživanje

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 spaja prva dva OID luka kao 40 * arc1 + arc2 i zbroj kodira u base-128 unutar UInt64, pa 2.999.3 postane 06 03 88 37 03, dok je stara pretvorba u Byte zadržala 55 i identifier tiho dekodirala kao 1.15.3
Većina PKI lukova nikad ne dosegne granicu, zato je bug i preživio — joint-iso-itu-t lukovi od 2.48 naviše trebaju dva bajta, a test drži 2.47 i 2.48 s obje strane

Udružena vrijednost drži se u UInt64 namjerno. DER_OID parsira lukove u Int64, pa drugi luk može biti sve do Int64.MaxValue, a pribrojavanje 80 za arc1 = 2 overflowa predznačeni 64-bitni cijeli broj. UInt64 nosi Int64.MaxValue + 80 bez omotavanja, a desetobajtni pomoćni buffer drži deset 7-bitnih grupa koje 64-bitna vrijednost treba. Test vektori vrijedni čuvanja su oni s obje strane granice: 2.47 mora ostati jedan bajt, a 2.48 mora postati dva

Što jamči CMS walker na strani čitanja?

PDFlibCMSRead jamči strukturu i ništa drugo: vraća bajtove koji sjede tamo gdje kaže RFC 5652 i ne provjerava potpis, digest ni razdoblje valjanosti. Walker prima samo DER, pa DERReadTLV odbija indefinite duljine i višebajtne brojeve tagova, a BER-kodirani CMS od neskladnog potpisnika prijavi nula certifikata umjesto djelomične domišljaje. Certifikati atributa i ostale CertificateChoices alternative preskaču se jer ih ništa nizvodno ne može iskoristiti. Kriptografska verifikacija ostaje kod koda koji je posjeduje, pa počinje s provjerama pokrivenosti bajtova opisanim u PAdES potpisivanju i validaciji ByteRangea u Delphiju, a nastavlja se s razvrstavanjem što se promijenilo nakon što je PDF potpisan

Šira lekcija vrijedi za svaki binarni format: „unutar buffera" svojstvo je memory safetyja, „unutar roditelja" svojstvo je ispravnosti, a parser treba oboje. Isto razmišljanje o neprijateljskim duljinama provlači se kroz otvrđivanje Pascal PDF parsera protiv zlonamjernih datoteka. Izvlačenje certifikata, gradnja lanca i long-term validation API-ji o kojima ovdje biva riječi isporučuju se uz losLab PDF Library za Delphi, za Delphi, C++Builder i Lazarus