Tehnički članak

CMS potpis u Delphiju: DER granice i OID lukovi

PDF Library for Delphi (PDFlibPas) izvlači sertifikate iz PDF potpisa čistim DER prolazom kroz CMS SignedData smešten u /Contents, bez ikakve uloge CryptoAPI-ja. Od v3.539.10 svako ugnježđeno čitanje ograđeno je roditeljskim elementom, nula-dopuna iza CMS-a seče se na dužinu koju sam CMS deklariše, a object identifieri koduju svoj objedinjeni prvi podidentifikator u base-128. Pravilo o granicama i OID popravka zamenile su kod koji je davao pogrešne odgovore bez podizanja greške, a pravilo o dopuni čuva strožijeg čitača od odbijanja pravih potpisa

Strana čitanja je važnija nego što izgleda. Alati za dugoročnu validaciju moraju prvo da izvuku sertifikat potpisnika i njegove izdavače iz postojećeg potpisa pre nego što dođu do podataka o opozivu, izveštaj revizije mora da kaže ko je potpisao, a Lazarus build na Linuxu nema Windows funkcije za poruke na koje bi se oslonio. Parser u takvoj poziciji retko pada na lošem ulazu. Bolni režim kvara je broj sertifikata koji uključuje bajtove pripadajuće komšiji, poklapanje potpisnika učinjeno na pogrešnom polju, ili OID koji tiho postane sasvim drugi OID. Potpisni pipeline izgrađen na takvoj osnovi javlja samouverene besmislice

Izvlačenje sertifikata potpisnika iz potpisanog PDF-a

Pet metoda TPDFlib-a pokriva stranu čitanja, i sve uzimaju InputFile, Password, FieldName: svaki poziv otvara fajl samo za čitanje, odgovori i ponovo ga zatvori. GetSignatureEmbeddedCertificateCount i GetSignatureEmbeddedCertificateDER nabrajaju sertifikate iz skupa u redosledu enkodovanja, GetSignatureSignerCertificateDER vraća sertifikat koji je proizveo dati SignerInfo, a GetSignatureCertificateChainLength / GetSignatureCertificateChainDER idu od tog potpisnika ka najdaljem izdavaču koji potpis sam nosi. Indeksi kreću od nule. Rezultate držite u AnsiString-u, i baš tako ih biblioteka i vraća: DER blob proveden kroz string ili TStrings prolazi kroz konverziju skupa znakova i vraća se pokvaren

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;

Dve stvari u tom izlazu traže pažnju. Broj 0 nije dijagnoza: nepostojeće polje, pogrešna lozinka, blob koji nije DER, i SignedData koji jednostavno izostavi opcioni skup sertifikata — sve se to vraća kao 0 ili prazan string, pa uz broj logujte i ime polja. I lanac koji se završi pre samoizdatog sertifikata takođe nije greška. Graditelj lanca koristi samo sertifikate ugrađene u potpis, pa preostale izdavače treba dovući preko adresa koje GetCertificateIssuerURLs prijavi

Koliki deo /Contents je zapravo CMS?

Samo prefiks koji spoljašnji SEQUENCE deklariše pripada CMS-u, i PLTrimCMSPadding seče sve iza njega. Potpisnik rezerviše /Contents heksadecimalni string pre nego što CMS uopšte postoji, jer /ByteRange opisan u ISO 32000-1 §12.8.1 mora prvo da bude fiksiran, pa je slot dimenzionisan darežljivo a neiskorišćeni rep su nule. PLTrimCMSPadding pročita prvi TLV, zahteva tag $30 i vraća bajtove do kraja tog elementa; sve što ne počinje korektno oblikovanim SEQUENCE-om vraća se kao prazno. Taj najviši nivo je jedino mesto gde su završni bajtovi legalni, i ta razlika znači za sledeći odeljak: strogo pravilo „element mora da potroši ceo bafer“ odbilo bi svaki potpis iz stvarnog sveta, dok labavo pravilo primenjeno na svakoj dubini pušta ugnježđena polja da čitaju bajtove koji nisu njihovi

PDFlibPas PLTrimCMSPadding čita prvi TLV rezervisanog /Contents heksadecimalnog stringa, zahteva tag $30 i seče nula-dopunu na dužinu koju spoljašnji SEQUENCE deklariše, a vraća prazan rezultat kada bafer ne počinje korektno oblikovanim SEQUENCE-om
Završni bajtovi legalni su samo na najvišem nivou, gde rezervisani slot mora da ostane fiksiran za /ByteRange — dublja čitanja dobijaju pravilo ograđeno roditeljem

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

Ugnježđeni element je validan samo ako se završava unutar svog roditelja, i provera protiv kraja bafera to ne dokazuje. Niskonivojni DERReadTLV u PDFlibASN1 ograđuje svaki element celim stringom, što je prava provera za najspoljašnji objekat a pogrešna za sve ispod njega. Zamislite SignerInfo čiji issuerAndSerialNumber deklariše 40 bajtova dok issuer Name unutar njega tvrdi 60. Svaki bajt je i dalje u baferu, pa čitač ograđen baferom prihvati Name, pročita serijski broj iz digest algoritma koji sledi, i onda taj par uporedi sa ugrađenim sertifikatima. Pre v3.539.10 CMS walker je čitao baš tako. Popravka je mali omotač koji u svako čitanje unosi krajnju poziciju roditelja

PDFlibPas ograđuje svako ugnježđeno DER čitanje roditeljskim elementom: issuer Name od 60 bajtova unutar issuerAndSerialNumber od 40 bajtova prihvata stari DERReadTLV ograđen baferom, koji zatim serijski broj čita iz digestAlgorithm, dok ReadTLVWithin odbija svaki element koji se završava iza ParentEnd
Unutar bafera je memory safety, unutar roditelja je ispravnost — PDFlibPas provlači krajnji offset roditelja kroz svaki CMS nivo, da neprijateljska dužina ne može da pozajmi komšijske bajtove
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // u roditelju ništa nije ostalo: odbij da započneš čitanje
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // Offset je sada jedan iza elementa; ne sme preći roditelja
  Result := Offset <= ParentEnd;
end;

// svaki nivo beleži svoj kraj i predaje ga nadole:
//   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 omotač, polja SignedData sve do signerInfos, SignerIdentifier u oba njegova oblika, issuerAndSerialNumber i [0] subjectKeyIdentifier (RFC 5652 §5.3), i polja tbsCertificate pročitana iz svakog ugrađenog sertifikata pri poklapanju potpisnika. Unutar skupa sertifikata i skupa signerInfos, element koji pređe kraj skupa zaustavlja petlju: PLExtractCMSCertificates vraća sertifikate koje je već prihvatio i nikada ne zalepi sledeće crls ili signerInfos bajtove na poslednji. Poklapanje issuer-and-serial takođe zahteva obe polovine, jer je serijski broj jedinstven samo unutar jednog izdavača

Zašto je 2.999.3 izašao kao 1.15.3?

Prva dva luka OID-a spajaju se u jedan podidentifikator, a ne u jedan bajt, i taj podidentifikator se base-128 koduje kao i svaki drugi luk. X.690 §8.19.4 definiše ga kao 40 * arc1 + arc2; stariji DER_OID upisivao je tu vrednost kroz Byte(...), što je tačno samo do 127, vrednosti od 2.47. Za 2.999 zbir je 1079, byte cast zadrži 55, a 55 se dekoduje kao 1.15, pa identifikator tiho imenuje sasvim drugu granu stabla. Vrednosti od 128 do 255 padaju drugačije, emitujući jedan bajt sa postavljenim continuation bitom koji proguta sledeći luk. Većina PKI identifikatora (1.2.840..., 2.5.29..., 0.4.0...) nikada ne dodirne tu granicu, pa je i preživela; joint-iso-itu-t lukovi od 2.48 naviše je dodiruju. DER_OID služi i enkoderu za signed attributes i tražilici u DERFindExtensionByOID i proveri SignedData content-type-a, pa je pogrešno kodovanje 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 base-128 koduje zbir u UInt64, pa 2.999.3 postaje 06 03 88 37 03, dok je stari Byte cast zadržavao 55 i tiho dekodovao identifikator kao 1.15.3
Većina PKI lukova nikada ne dodirne granicu, pa je bug i preživeo — joint-iso-itu-t lukovi od 2.48 naviše trebaju dva bajta, a test drži 2.47 i 2.48 s obe strane

Objedinjena vrednost se drži u UInt64-u sasvim namerno. DER_OID parsira lukove u Int64, pa zakonit drugi luk može biti i do Int64.MaxValue, a dodavanje 80 za arc1 = 2 prekorači potpisani 64-bitni integer. UInt64 nosi Int64.MaxValue + 80 bez prelivanja, a desetobajtni pomoćni bafer drži deset 7-bitnih grupa koliko 64-bitna vrednost traži. Test vektori vredni čuvanja su oni s obe strane granice: 2.47 mora da ostane jedan bajt, a 2.48 mora da postane dva

Šta garantuje CMS walker na strani čitanja?

PDFlibCMSRead garantuje strukturu i ništa više: vraća bajtove koji stoje tamo gde RFC 5652 kaže da treba da stoje i ne proverava potpis, digest ni period validnosti. Walker prima samo DER, pa DERReadTLV odbija indefinite dužine i višebajtne brojeve tagova, a BER-kodovan CMS od nenormativnog potpisnika javlja nula sertifikata umesto delimične pretpostavke. Atribute sertifikati i ostale CertificateChoices alternative preskaču se jer ih niko nizvodno ne može iskoristiti. Kriptografska verifikacija ostaje kod koda koji je poseduje: počinje proverama prekrivenosti bajtova opisanim u tekstu o PAdES potpisivanju i ByteRange validaciji u Delphiju i nastavlja se sa klasifikacijom onoga što se promenilo nakon što je PDF potpisan

Šira lekcija važi za svaki binarni format: „unutar bafera“ je osobina memory safety-ja, „unutar roditelja“ je osobina ispravnosti, i parseru treba obe. Isto razmišljanje o neprijateljskim dužinama prolazi kroz tekst o ojačavanju Pascal PDF parsera protiv zlonamernih fajlova. API-ji za izvlačenje sertifikata, izgradnju lanca i dugoročnu validaciju razmatrani ovde isporučuju se uz losLab PDF Library for Delphi, za Delphi, C++Builder i Lazarus