Tehnički članak

Nikad ne hodajte unazad kroz DER oktete dužine

PDFium Component pronalazi početak ugnježđene CMS strukture iz dužine njenog sadržaja, nikad hodanjem unazad preko okteta dužine, jer je bajt neposredno pre sadržaja poslednji oktet dužine i ne govori ništa o tome koliko ih prethodi. CmsHeaderStart u FPdfCms.pas izvodi dužinu zaglavlja iz ContentLen umesto toga, što DER čini egzaktnim, i to je ono što čuva AddSignatureTimestampToCms od kvarenja svakog CMS-a čiji je skup sertifikata duži od 127 bajtova

U pitanju je PAdES B-T nadogradnja. Atribut signature-time-stamp, onaj koji ETSI EN 319 122-1 klauzula 5.3 definiše pod OID-om 1.2.840.113549.1.9.16.2.14, mora da sleti u unsignedAttrs strukture SignerInfo opisane u RFC 5652 klauzula 5.3, a po definiciji može da se doda samo pošto vrednost potpisa postoji, jer se timestamp token računa nad tom vrednošću. Dakle CMS je već izgrađen i već potpisan kada token stigne. Dodavanje jednog atributa menja dužinu SignerInfo-a, što menja dužinu SET-a signerInfos, zatim SignedData, zatim EXPLICIT omotača [0], zatim spoljnog ContentInfo. Svako okružujuće zaglavlje mora biti ponovo emitovano, a sve što nije na toj putanji mora biti preneto bajt za bajt. Tekst o B-LT i B-LTA pokriva šta vam token donosi; ovaj članak je o četiri bajta ispred skupa sertifikata koje je rebuild uporno pogrešno pogađao

Zašto dodavanje timestamp-a zahteva tag offset brata?

Zato što rebuild ponovo koristi četiri brata SET-a signerInfos verbatim, a reader prijavljuje gde je njihov sadržaj, ne gde je njihov tag. TDerReader.ReadTlv vraća bajt taga, offset sadržaja, dužinu sadržaja i offset sledećeg TLV-a. To je prava površina za spuštanje u strukturu, ali da biste kopirali ceo element treba vam oktet na kojem mu stoji tag, a jedino što pozivalac drži je ContentOffs. CmsSliceTlv postoji da premosti tu prazninu: dat mu offset sadržaja i dužinu, vraća tag, oktete dužine i sadržaj kao jedan bafer, a AddSignatureTimestampToCms poziva ga za OID contentType, INTEGER version, SET digestAlgorithms, SEQUENCE encapContentInfo i, kada postoji, skup certificates [0]

// Unutar AddSignatureTimestampToCms: zaroni, iseci braću verbatim
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;
// opciona certificates [0]
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 + okteti dužine + sadržaj
  R.Position:= CN;
end;

Od tih pet isečaka, četiri su sićušna: OID od jedanaest bajtova, INTEGER od tri bajta, skup algoritama sažimanja od sedamnaest bajtova, odvojeni encapContentInfo od trinaest bajtova. Skup sertifikata je onaj koji nosi sertifikat potpisnika i njegov lanac, a pravi X.509 sertifikat ide na nekoliko stotina bajtova u najmanju ruku. Skup sertifikata je zato jedini isečak čiji su okteti dužine ikad u dugoj formi, i to je isečak koji stari helper nije mogao da locira

Šta dodavanje PAdES B-T timestamp-a ponovo gradi u CMS-u: CmsSliceTlv kopira contentType, version, digestAlgorithms, encapContentInfo i skup sertifikata bajt za bajt, skup sertifikata je jedini isečak dovoljno dug da napusti kratku formu dužine, a svako okružujuće zaglavlje od SignerInfo-a do ContentInfo ponovo se emituje
Potpisani deo ostaje netaknut po konstrukciji jer se prefiks SignerInfo-a do potpisnog OCTET STRING-a kopira verbatim, pa validator koji ponovo sažima signedAttrs vidi identične bajtove pre i posle nailaska timestamp-a

Zašto se DER okteti dužine ne mogu čitati unazad?

Zato što je broj okteta dužine smešten u prvom od njih, a čitajući od sadržaja unazad prvo nailazite na poslednji. X.690 klauzula 8.1.3.4 definiše kratku formu: jedan oktet, bit 8 obrisan, bitovi 7 do 1 drže dužinu od 0 do 127. Klauzula 8.1.3.5 definiše dugu formu: početni oktet sa postavljenim bitom 8 čiji bitovi 7 do 1 daju broj narednih okteta, a zatim ti okteti nose dužinu kao neoznačen celi broj u big-endian obliku. Ništa u pravilu ne obeležava naredni oktet kao naredni. Njegov bit 8 je bit veličine kao i svaki drugi, pa hodanje unazad koje testira gornji bit Buf[ContentOffs- 1] testira bit podataka i zatim čita njegovih donjih sedam bitova kao broj

// Stari helper, kojem je dat samo offset sadržaja
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // sleće na POSLEDNJI oktet dužine
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // ima smisla samo za PRVI
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

// Zaglavlje skupa sertifikata od 1500 bajtova:  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> bit 8 postavljen, $DC and $7F= 92
//   Result= ContentOffs- 94     (tag je na ContentOffs- 4)

Uzmite zaglavlje skupa sertifikata koji drži 1500 bajtova sertifikata, A0 82 05 DC. Hod sleće na DC, vidi postavljen gornji bit, izvlači 92 iz donjih sedam bitova i prijavljuje tag 94 bajta pre sadržaja, kada je 4 bajta pre njega. U SignedData koji gradi BuildSignedData, sadržaj skupa sertifikata stoji samo nekoliko desetina bajtova u CMS-u, pa izračunati offset nije bio samo preran nego negativan, a stari kod je štitio ContentOffs- 1 od pada ispod nule, ne svoj konačni rezultat. CmsSliceTlv je zatim uzeo isečak devedesetak bajtova duži od elementa, počevši pre bafera, i ponovo izgrađeni SignedData nosio je taj isečak tamo gde je trebalo da mu bude skup sertifikata. Dužina od tri okteta čiji poslednji oktet slučajno padne ispod $80, recimo A0 82 05 10, pala je na drugu stranu: hod ju je uzeo za oktet kratke forme i počeo isečak na 05, dva bajta prekasno i unutar okteta dužine, bez ikakvog taga. Ishod je bio pogrešan u oba slučaja, samo je smer varirao

Zašto se DER okteti dužine ne mogu čitati unazad: čitanje A0 82 05 DC od kraja sleće na poslednji oktet DC, čiji postavljen gornji bit daje lažni broj 92 i smešta tag 94 okteta prerano, dok A0 82 05 10 pada na drugu stranu i počinje isečak dva okteta prekasno unutar okteta dužine
Stari CmsHeaderStart štitio je međurezultat a ne svoj konačni rezultat, pa je isečak mogao čak i da počne pre bafera, a ponovo izgrađeni SignedData nosio je taj isečak tamo gde je pripadao skup sertifikata

Šta DER garantuje da izvođenje unapred bude egzaktno?

DER garantuje da je kodiranje dužine čista funkcija dužine. X.690 klauzula 10.1 ograničava DER na definitnu formu i zahteva najmanji broj okteta, što uklanja dve slobode koje BER dozvoljava: nedefinisanu formu, i dopunjavanje dužine u dugoj formi vodećim nula oktetima. Po tom pravilu dužina sadržaja ispod 128 ima tačno jedan oktet dužine, a svaka druga dužina ima jedan početni oktet plus tačno onoliko narednih okteta koliko dužina treba značajnih bajtova. Pozivalac CmsHeaderStart-a već drži ContentLen, jer ga je ReadTlv upravo vratio, pa je dužina zaglavlja izračunljiva bez pogleda na ijedan bajt bafera

Izvođenje unapred koje DER garantuje: dužina sadržaja 127 kodira se kao A0 7F, 128 kao A0 81 80, 255 kao A0 81 FF, 256 kao A0 82 01 00 a 1500 kao A0 82 05 DC, pa dužina zaglavlja sledi iz samog ContentLen a TryReadTlvAt je već odbio svaku ne-minimalnu BER formu
Fiksture izgrađene od sertifikata od 32 i 64 bajta ostajale su unutar kratke forme gde hodanje unazad daje tačan odgovor iz pogrešnog razloga, i zato boundary suite sada prelazi 127, 128, 255 i 256 bajtova
// Isporučeni helper: izvedi zaglavlje iz dužine sadržaja.
// Po X.690 10.1 okteti dužine su funkcija ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // kratka forma, X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // početni oktet, X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // po jedan za svaki značajan bajt
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

Dva detalja čine ovo bezbednim a ne samo verovatnim. Prvo, pretpostavka da je ulaz DER sprovodi se uzvodno: TDerReader.TryReadTlvAt, na kojem je ReadTlv izgrađen, odbija nedefinisanu formu, odbija dužinu u dugoj formi čiji je prvi naredni oktet nula, i odbija jedan naredni oktet ispod $80. TLV koji stigne do CmsSliceTlv-a već je prošao te provere, pa ne-minimalna dužina u BER stilu ne može stići do izvođenja i naterati ga da slaže. Drugo, fallback za negativan rezultat sada štiti pravi odgovor, a ne međurezultat. Vredi reći da je reader oduvek znao offset taga: TDerTlv nosi i Offset i HeaderLength, i samo ih površina ReadTlv sa četiri izlazna parametra odbacuje. Njihovo vraćanje bilo bi čistiji dugoročni interfejs; isporučena ispravka zadržava tu površinu netaknutom i čini helper ispravnim po sopstvenim uslovima

Zašto su timestamp testovi prolazili sa bugom na mestu?

Zato što je svaki fikstura sertifikat bio dovoljno kratak da koristi kratku formu, a hodanje unazad je ispravno tačno za taj slučaj. Tests.PadesTimestamp.pas gradi svoj sertifikat potpisnika sa SetLength(SignerCertDer, 32) u jednom testu i 64 u drugom, ispunjen bajt rampom. Skup sertifikata od 32 bajta kodira se kao A0 20, a onaj od 64 bajta kao A0 40, svaki sa jednim oktetom dužine. Hodanje unazad od sadržaja sleće na taj jedan oktet, njegov gornji bit je obrisan jer je prvi i jedini oktet dužine, i helper daje tačan odgovor iz pogrešnog razloga. Suite od 1414 slučajeva bio je zelen, CMS sa timestamp-om se parsirao, validator prve faze prijavljivao je B-T, i svaka od tih provera izvršena je nad skupom sertifikata koji nijedan pravi dokument nikad nije sadržao

Opšte pravilo je koristan deo. Kad god putanja koda zavisi od toga kako je dužina kodirana, fikstura mora da pređe granicu kodiranja, a za DER to znači sadržaj duži od 127 bajtova, što forsira dugu formu, i po mogućstvu duži i od 255 bajtova, što forsira drugi naredni oktet. Ista disciplina važi za drugi slučaj iz tog pregleda gde samoverifikacija nije mogla da vidi DER odstupanje: nesortirani SET OF u signedAttrs bio je nevidljiv za round trip unutar istog izvora iz strukturno identičnog razloga, test je vežbao samo ulaze na kojima se pogrešan kod i ispravan kod slažu. Skica ispod poziva slice helper direktno, što znači izvoz tog helpera iz FPdfCms.pas za test build; ista granica je dohvatljiva i kroz javnu površinu ako BuildSignedData-u date lanac sa sertifikatom svake veličine i ponovo parsirate timestampovani rezultat

// Prikuj granicu: isečak kroz zaglavlje duge forme mora početi na tagu
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;
      // sadržaj počinje odmah posle zaglavlja; isečak mora biti ceo TLV
      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;

Gde rebuild i dalje povlači svoje granice

AddSignatureTimestampToCms je napisan za CMS koji emituje BuildSignedData, i njegove granice iz toga slede. Hod očekuje jedan SignerInfo i ponovo emituje samo njega, pa bi strani CMS sa više potpisnika vratio jedan potpis; prepoznaje opcioni skup certificates [0] ali ne i skup crls [1], i CMS koji ga nosi pada glasno uz izuzetak signerInfos SET expected a ne tiho pogrešnim isečkom. Novi unsignedAttrs drži jedan atribut, pa je pravilo redosleda SET OF iz X.690 klauzula 11.6 trivijalno zadovoljeno i ne traži sortiranje. A potpisani deo je netaknut po konstrukciji: prefiks SignerInfo-a do potpisnog OCTET STRING-a kopira se verbatim, i zato validator koji ponovo sažima signedAttrs vidi iste bajtove pre i posle dodavanja timestamp-a. Kada neko i pored toga odbije dokument, uzroci su obično drugde i vredi ih zasebno popisati

DER reader, pisac, CMS builder i ovo ubacivanje timestamp-a svi se isporučuju kao Pascal izvorni kod uz PDFium Delphi komponentu, i bug ovakvog oblika je argument za to: kada ponovo izgrađeni SignedData izađe devedeset bajtova predugačak, želite da čitate helper koji je isekao isečak i klauzulu X.690 koju je pogrešno pročitao, a ne stack trace iz crne kutije