Articol tehnic

Nu parcurgeți niciodată invers octeții de lungime DER

PDFium Component localizează începutul unei structuri CMS imbricate pornind de la lungimea conținutului, niciodată parcurgând invers peste octeții de lungime, pentru că octetul de dinaintea conținutului este ultimul octet de lungime și nu spune nimic despre câți îl precedă. CmsHeaderStart din FPdfCms.pas derivă în schimb lungimea antetului din ContentLen, ceea ce DER face exact, și exact asta împiedică AddSignatureTimestampToCms să corupă fiecare CMS a cărui mulțime de certificate este mai lungă de 127 de octeți

Scenariul este upgrade-ul PAdES B-T. Un atribut de marcă temporală de semnătură, cel pe care ETSI EN 319 122-1 clauza 5.3 îl definește sub OID-ul 1.2.840.113549.1.9.16.2.14, trebuie să ajungă în unsignedAttrs al lui SignerInfo descris în RFC 5652 clauza 5.3 și, prin definiție, poate fi adăugat doar după ce valoarea semnăturii există, pentru că tokenul de marcă temporală este calculat peste acea valoare. Așa că CMS-ul este deja construit și deja semnat când sosește tokenul. Adăugarea unui atribut schimbă lungimea lui SignerInfo, ceea ce schimbă lungimea mulțimii signerInfos, apoi a lui SignedData, apoi a învelișului EXPLICIT [0], apoi a lui ContentInfo exterior. Fiecare antet care le cuprinde trebuie reemis, iar tot ce nu se află pe acea cale trebuie dus mai departe octet cu octet. Ghidul despre B-LT și B-LTA acoperă ce vă cumpără tokenul; acest articol este despre cei patru octeți din fața mulțimii de certificate pe care reconstrucția îi greșea mereu

De ce are nevoie adăugarea unei mărci temporale de offset-ul tag-ului unui frate?

Pentru că reconstrucția refolosește identic patru frați ai mulțimii signerInfos, iar reader-ul raportează unde se află conținutul lor, nu unde se află tag-ul lor. TDerReader.ReadTlv întoarce octetul de tag, offset-ul conținutului, lungimea conținutului și offset-ul următorului TLV. Aceasta este suprafața corectă pentru a coborî într-o structură, dar pentru a copia un element întreg aveți nevoie de octetul în care stă tag-ul lui, iar singurul lucru pe care îl ține un apelant este ContentOffs. CmsSliceTlv există ca să acopere acest gol: primind un offset și o lungime de conținut, întoarce tag-ul, octeții de lungime și conținutul ca un singur buffer, iar AddSignatureTimestampToCms îl apelează pentru OID-ul contentType, INTEGER-ul version, mulțimea digestAlgorithms, SEQUENCE-ul encapContentInfo și, când există, mulțimea certificates [0]

// În interiorul AddSignatureTimestampToCms: coboară, decupează frații identic
if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> Byte(asnSequence)) then
  raise Exception.Create('CMS: encapContentInfo SEQUENCE expected');
SdEncapTlv:= CmsSliceTlv(CmsDer, CO, CL);
R.Position:= CN;
// certificates [0] opțional
HasCerts:= (R.Position< SdEnd) and (CmsDer[R.Position]= $A0);
if HasCerts then
begin
  if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> $A0) then
    raise Exception.Create('CMS: certificates [0] malformed');
  SdCertsTlv:= CmsSliceTlv(CmsDer, CO, CL);   // tag + octeți de lungime + conținut
  R.Position:= CN;
end;

Dintre cele cinci decupaje, patru sunt minuscule: un OID de unsprezece octeți, un INTEGER de trei octeți, o mulțime de algoritmi de digest de șaptesprezece octeți, un encapContentInfo detașat de treisprezece octeți. Mulțimea de certificate este cea care poartă certificatul semnatarului și lanțul lui, iar un certificat X.509 real ajunge la câteva sute de octeți, cel puțin. Mulțimea de certificate este așadar singurul decupaj ai cărui octeți de lungime sunt vreodată în forma lungă și este decupajul pe care vechiul helper nu îl putea localiza

Ce reconstruiește adăugarea unei mărci temporale PAdES B-T într-un CMS: CmsSliceTlv copiază contentType, version, digestAlgorithms, encapContentInfo și mulțimea de certificate octet cu octet, mulțimea de certificate este singurul decupaj suficient de lung ca să iasă din forma scurtă de lungime, iar fiecare antet care le cuprinde, de la SignerInfo până la ContentInfo, este reemis
Partea semnată este neatinsă prin construcție, pentru că prefixul lui SignerInfo până la OCTET STRING-ul de semnătură este copiat identic, așa că un validator care recalculează digestul pe signedAttrs vede octeți identici înainte și după ce marca temporală ajunge acolo

De ce nu pot fi parcurși invers octeții de lungime DER?

Pentru că numărul de octeți de lungime este stocat în primul dintre ei, iar citind de la conținut spre înapoi îl întâlniți mai întâi pe ultimul. X.690 clauza 8.1.3.4 definește forma scurtă: un octet, bitul 8 pe zero, biții 7 până la 1 conținând o lungime de la 0 la 127. Clauza 8.1.3.5 definește forma lungă: un octet inițial cu bitul 8 setat, ai cărui biți 7 până la 1 dau numărul de octeți următori, urmat de acei octeți care poartă lungimea ca întreg fără semn big-endian. Nimic din regulă nu marchează un octet următor ca fiind următor. Bitul lui 8 este un bit de magnitudine ca oricare altul, așa că o parcurgere inversă care testează bitul de sus al lui Buf[ContentOffs- 1] testează un bit de date și apoi citește cei șapte biți de jos ai lui ca pe un număr

// Vechiul helper, primind doar offset-ul conținutului
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // ajunge pe ULTIMUL octet de lungime
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // are sens doar pentru PRIMUL
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

// Antetul unei mulțimi de certificate de 1500 de octeți:  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> bit 8 setat, $DC and $7F= 92
//   Result= ContentOffs- 94     (tag-ul este la ContentOffs- 4)

Luați antetul unei mulțimi de certificate care ține 1500 de octeți de certificate, A0 82 05 DC. Parcurgerea ajunge pe DC, vede un bit de sus setat, extrage 92 din cei șapte biți de jos și raportează tag-ul la 94 de octeți înaintea conținutului, când el este la 4 octeți înaintea lui. Într-un SignedData construit de BuildSignedData, conținutul mulțimii de certificate se află la doar câteva zeci de octeți în CMS, așa că offset-ul calculat nu era doar prea devreme, ci negativ, iar codul vechi proteja ContentOffs- 1 de a coborî sub zero, nu rezultatul lui final. CmsSliceTlv lua apoi un decupaj cu nouăzeci și ceva de octeți mai lung decât elementul, începând înaintea buffer-ului, iar SignedData reconstruit purta acel decupaj exact acolo unde ar fi trebuit să fie mulțimea lui de certificate. O lungime de trei octeți al cărei ultim octet cădea sub $80, de exemplu A0 82 05 10, eșua în cealaltă direcție: parcurgerea îl lua drept un octet de formă scurtă și începea decupajul la 05, cu doi octeți prea târziu și în interiorul octeților de lungime, fără niciun tag. Rezultatul era greșit în ambele cazuri, doar direcția diferea

De ce nu pot fi parcurși invers octeții de lungime DER: citirea lui A0 82 05 DC de la final ajunge pe ultimul octet DC, al cărui bit de sus setat dă un număr fals de 92 și plasează tag-ul cu 94 de octeți prea devreme, în timp ce A0 82 05 10 eșuează în cealaltă direcție și începe decupajul cu doi octeți prea târziu, în interiorul octeților de lungime
Vechiul CmsHeaderStart proteja scăderea intermediară în loc de rezultatul ei final, așa că un decupaj putea începe chiar înaintea buffer-ului, iar SignedData reconstruit purta acel decupaj exact unde îi era locul mulțimii de certificate

Ce garantează DER, făcând derivarea înainte exactă?

DER garantează că encodarea lungimii este o funcție pură a lungimii. X.690 clauza 10.1 restricționează DER la forma definită și cere numărul minim de octeți, ceea ce elimină cele două libertăți pe care le permite BER: forma nedefinită și umplerea unei lungimi de formă lungă cu octeți zero la început. Sub acea regulă, o lungime de conținut sub 128 are exact un octet de lungime, iar orice altă lungime are un octet inițial plus exact atâția octeți următori cât îi trebuie lungimii octeți semnificativi. Apelantul lui CmsHeaderStart ține deja ContentLen, pentru că ReadTlv tocmai l-a întors, așa că lungimea antetului este calculabilă fără să se uite la vreun octet din buffer

Derivarea înainte pe care o garantează DER: o lungime de conținut de 127 se encodează ca A0 7F, 128 ca A0 81 80, 255 ca A0 81 FF, 256 ca A0 82 01 00 și 1500 ca A0 82 05 DC, așa că lungimea antetului decurge doar din ContentLen, iar TryReadTlvAt a respins deja fiecare formă BER ne-minimală
Fixture-urile construite din certificate de 32 și 64 de octeți rămâneau în forma scurtă, unde parcurgerea inversă răspunde corect din motivul greșit, motiv pentru care suita de limite traversează acum 127, 128, 255 și 256 de octeți
// Helper-ul livrat: derivă antetul din lungimea conținutului.
// Sub X.690 10.1 octeții de lungime sunt o funcție de ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // formă scurtă, X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // octetul inițial, X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // unul per octet semnificativ
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

Două detalii fac asta sigură, nu doar plauzibilă. Primul: presupunerea că intrarea este DER este impusă în amonte: TDerReader.TryReadTlvAt, pe care se construiește ReadTlv, respinge forma nedefinită, respinge o lungime de formă lungă al cărei prim octet următor este zero și respinge un singur octet următor sub $80. Un TLV care ajunge la CmsSliceTlv a trecut deja de acele verificări, așa că o lungime ne-minimală de tip BER nu poate ajunge la derivare și nu o poate face să mintă. Al doilea: calea de rezervă pentru un rezultat negativ protejează acum răspunsul real, nu un intermediar. Merită spus că reader-ul știa de la început offset-ul tag-ului: TDerTlv poartă atât Offset, cât și HeaderLength, iar doar suprafața ReadTlv cu patru parametri de ieșire le pierde. Întoarcerea lor ar fi interfața curată pe termen lung; reparația livrată păstrează acea suprafață intactă și face helper-ul corect în termenii lui

De ce au trecut testele de marcă temporală cu bug-ul în loc?

Pentru că fiecare certificat din fixture era suficient de scurt ca să folosească forma scurtă, iar parcurgerea inversă este corectă exact pentru acel caz. Tests.PadesTimestamp.pas își construiește certificatul de semnătură cu SetLength(SignerCertDer, 32) într-un test și cu 64 în altul, umplut cu o rampă de octeți. O mulțime de certificate de 32 de octeți se encodează ca A0 20, iar una de 64 de octeți ca A0 40, fiecare cu un singur octet de lungime. Parcurgerea inversă de la conținut ajunge pe acel unic octet, bitul lui de sus este zero pentru că este primul și singurul octet de lungime, iar helper-ul răspunde corect din motivul greșit. Suita de 1414 cazuri era verde, CMS-ul cu marcă temporală se parsa, validatorul de etapa 1 raporta B-T, iar fiecare dintre verificările acelea rula pe o mulțime de certificate pe care niciun document real nu a conținut-o vreodată

Regula generală este partea utilă. Ori de câte ori o cale de cod depinde de felul în care este encodată o lungime, fixture-ul trebuie să traverseze granița de encodare, iar pentru DER asta înseamnă conținut mai lung de 127 de octeți, ceea ce forțează forma lungă, și ideal mai lung de 255 de octeți, ceea ce forțează un al doilea octet următor. Aceeași disciplină se aplică și celuilalt caz din acea revizuire în care autoverificarea nu putea vedea o abatere DER: SET OF-ul nesortat din signedAttrs era invizibil pentru un round-trip în aceeași origine dintr-un motiv structural identic, testul exercita doar intrări pe care codul greșit și cel corect cad de acord. Schița de mai jos apelează direct helper-ul de decupare, ceea ce înseamnă că trebuie exportat din FPdfCms.pas pentru build-ul de teste; aceeași graniță este accesibilă prin suprafața publică dându-i lui BuildSignedData un certificat de lanț de fiecare dimensiune și reparsând rezultatul cu marcă temporală

// Fixează granița: un decupaj printr-un antet de formă lungă trebuie să înceapă la tag
const
  Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);

procedure TCmsSliceTests.HeaderStart_LongFormLengths;
var
  W: TDerWriter;
  Content, Tlv: TBytes;
  I, Len: Integer;
begin
  W:= TDerWriter.Create;
  try
    for I:= Low(Lens) to High(Lens) do
    begin
      Len:= Lens[I];
      SetLength(Content, Len);
      Tlv:= W.Wrap($A0, Content);            // A0 7F / A0 81 80 / A0 82 05 DC ...
      W.Clear;
      // conținutul începe imediat după antet; decupajul trebuie să fie tot TLV-ul
      Assert.AreEqual(Length(Tlv),
        Length(CmsSliceTlv(Tlv, Length(Tlv)- Len, Len)),
        'slice through header of a '+ IntToStr(Len)+ '-byte content');
    end;
  finally
    W.Free;
  end;
end;

Unde își trasează reconstrucția încă limitele

AddSignatureTimestampToCms este scris pentru CMS-ul pe care îl emite BuildSignedData, iar limitele lui decurg de aici. Parcurgerea așteaptă un singur SignerInfo și reemite doar pe acela, așa că un CMS străin cu mai mulți semnatari ar reveni cu un singur semnatar; recunoaște o mulțime certificates [0] opțională, dar nu o mulțime crls [1], iar un CMS care duce una eșuează zgomotos cu excepția signerInfos SET expected, în loc să decupeze greșit în tăcere. Noul unsignedAttrs ține un singur atribut, așa că regula de ordonare SET OF din X.690 clauza 11.6 este îndeplinită trivial și nu are nevoie de sortare. Iar partea semnată este neatinsă prin construcție: prefixul lui SignerInfo până la OCTET STRING-ul de semnătură este copiat identic, motiv pentru care un validator care recalculează digestul pe signedAttrs vede aceiași octeți înainte și după adăugarea mărcii temporale. Când unul tot respinge documentul, cauzele sunt de obicei în altă parte și merită propria listă de verificare

Reader-ul DER, scriitorul, constructorul de CMS și această injectare de marcă temporală sunt toate livrate ca sursă Pascal împreună cu componenta PDFium pentru Delphi, iar un bug de această formă este argumentul pentru asta: când un SignedData reconstruit iese cu nouăzeci de octeți prea lung, vrei să citești helper-ul care a tăiat decupajul și clauza din X.690 pe care a citit-o greșit, nu un stack trace dintr-o cutie neagră