Tehnični članak

Nikoli ne hodite po oktetih dolžine DER vzvratno

PDFium Component začetek gnezdene strukture CMS poišče iz dolžine njene vsebine in nikoli z vzvratno hojo po oktetih dolžine, ker je bajt tik pred vsebino zadnji oktet dolžine in ne pove nič o tem, koliko jih je pred njim. CmsHeaderStart v FPdfCms.pas dolžino glave izpelje iz ContentLen, kar DER naredi natančno, in prav to preprečuje, da bi AddSignatureTimestampToCms pokvaril vsak CMS, katerega nabor potrdil je daljši od 127 bajtov

Nastavitev je nadgradnja PAdES B-T. Atribut časovnega žiga podpisa, tisti, ki ga ETSI EN 319 122-1 razdelek 5.3 definira pod OID 1.2.840.113549.1.9.16.2.14, se mora znajti v unsignedAttrs zapisa SignerInfo, opisanega v RFC 5652 razdelek 5.3, po definiciji pa ga je mogoče dodati šele, ko vrednost podpisa obstaja, ker se žeton časovnega žiga izračuna nad to vrednostjo. Ko žeton prispe, je CMS torej že zgrajen in že podpisan. Dodatek enega atributa spremeni dolžino SignerInfo, to spremeni dolžino niza SET signerInfos, nato SignedData, nato ovoja EXPLICIT [0], nato zunanjega ContentInfo. Vsako glavo, ki koga objema, je treba oddati znova, vse, kar ni na tej poti, pa je treba prenesti bajt za bajtom. Pregled B-LT in B-LTA pokriva, kaj vam žeton prinese; ta članek je o štirih bajtih pred naborom potrdil, ki jih je obnova vedno znova zamočila

Zakaj dodajanje časovnega žiga potrebuje odmik oznake sorodnika?

Ker obnova dobesedno uporabi štiri sorodnike niza SET signerInfos, bralnik pa poroča, kje je njihova vsebina, in ne, kje je njihova oznaka. TDerReader.ReadTlv vrne bajt oznake, odmik vsebine, dolžino vsebine in odmik naslednjega TLV. To je prava površina za spuščanje v strukturo, a za kopiranje celega elementa potrebujete oktet, na katerem sedi njegova oznaka, klicatelj pa drži le ContentOffs. CmsSliceTlv obstaja zato, da premosti to vrzel: iz odmika in dolžine vsebine vrne oznako, oktete dolžine in vsebino kot en vmesni pomnilnik, AddSignatureTimestampToCms pa ga pokliče za OID contentType, INTEGER version, niz SET digestAlgorithms, SEQUENCE encapContentInfo in, kadar je prisoten, nabor certificates [0]

// Znotraj AddSignatureTimestampToCms: spusti se, sorodnike odreži dobesedno
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;
// neobvezni 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);   // oznaka + okteti dolžine + vsebina
  R.Position:= CN;
end;

Od teh petih izrezkov so štirje drobni: OID z enajstimi bajti, INTEGER s tremi, niz algoritmov zgoščevanja s sedemnajstimi in ločen encapContentInfo s trinajstimi. Nabor potrdil je tisti, ki nosi potrdilo podpisnika in njegovo verigo, resnično potrdilo X.509 pa sega vsaj nekaj sto bajtov daleč. Nabor potrdil je zato edini izrezek, katerega okteti dolžine so kdaj v dolgi obliki, in prav tega izrezka stari pomožni element ni znal najti

Kaj dodajanje časovnega žiga PAdES B-T znova zgradi v CMS: CmsSliceTlv dobesedno kopira contentType, version, digestAlgorithms, encapContentInfo in nabor potrdil, nabor potrdil je edini izrezek, ki je dovolj dolg, da zapusti kratko obliko dolžine, vsaka glava, ki koga objema, od SignerInfo do ContentInfo, pa se odda znova
Podpisani del ostane po zgradbi nedotaknjen, ker se predpona SignerInfo do OCTET STRING podpisa kopira dobesedno, zato preverjevalnik, ki znova zgošča signedAttrs, vidi enake bajte pred časovnim žigom in po njem

Zakaj oktetov dolžine DER ni mogoče brati vzvratno?

Ker je število oktetov dolžine shranjeno v prvem od njih, pri branju od vsebine nazaj pa najprej naletite na zadnjega. X.690 razdelek 8.1.3.4 določa kratko obliko: en oktet, bit 8 ni nastavljen, biti od 7 do 1 nosijo dolžino od 0 do 127. Razdelek 8.1.3.5 določa dolgo obliko: začetni oktet z nastavljenim bitom 8, katerega biti od 7 do 1 povedo število naslednjih oktetov, tem pa sledijo tisti okteti, ki nosijo dolžino kot nepredznačeno veliko-endenjsko celo število. Nobeno pravilo ne označi naslednjega okteta kot naslednjega. Njegov bit 8 je bit velikosti kot vsak drug, zato vzvratna hoja, ki preizkusi zgornji bit Buf[ContentOffs- 1], preizkuša podatkovni bit in nato njegovih spodnjih sedem bitov prebere kot število

// Stari pomožni element, ki dobi le odmik vsebine
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // pristane na ZADNJEM oktetu dolžine
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // smiselno le za PRVEGA
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

// Glava nabora potrdil s 1500 bajti:  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> bit 8 nastavljen, $DC and $7F= 92
//   Result= ContentOffs- 94     (oznaka je pri ContentOffs- 4)

Vzemite glavo nabora potrdil s 1500 bajti potrdil, A0 82 05 DC. Hoja pristane na DC, vidi nastavljen zgornji bit, iz spodnjih sedmih bitov izlušči 92 in prijavi oznako 94 bajtov pred vsebino, medtem ko je 4 bajte pred njo. V SignedData, ki ga zgradi BuildSignedData, vsebina nabora potrdil sedi le nekaj deset bajtov v CMS, zato izračunani odmik ni bil le prezgodnji, ampak negativen, stara koda pa je ContentOffs- 1 varovala pred padcem pod nič in ne svojega končnega rezultata. CmsSliceTlv je nato vzel izrezek devetdeset in nekaj bajtov daljši od elementa, ki se je začel pred vmesnim pomnilnikom, in obnovljeni SignedData je ta izrezek nosil tam, kjer bi moral biti njegov nabor potrdil. Dolžina s tremi okteti, katere zadnji oktet je slučajno padel pod $80, recimo A0 82 05 10, pa je odpovedala v drugo smer: hoja ga je imela za oktet kratke oblike in izrezek začela pri 05, dva bajta prepozno in znotraj oktetov dolžine, brez vsakršne oznake. Izid je bil napačen v obe smeri, razlikovala se je le smer

Zakaj oktetov dolžine DER ni mogoče brati vzvratno: branje A0 82 05 DC od konca pristane na zadnjem oktetu DC, katerega nastavljeni zgornji bit da napačno število 92 in postavi oznako 94 oktetov prezgodaj, A0 82 05 10 pa odpove v drugo smer in izrezek začne dva okteta prepozno, znotraj oktetov dolžine
Stari CmsHeaderStart je varoval vmesno odštevanje in ne svojega končnega rezultata, zato se je izrezek lahko začel celo pred vmesnim pomnilnikom, obnovljeni SignedData pa je ta izrezek nosil tam, kjer bi moral biti njegov nabor potrdil

Kaj DER jamči, da izpeljava naprej postane natančna?

DER jamči, da je kodiranje dolžine čista funkcija dolžine. X.690 razdelek 10.1 omeji DER na določeno obliko in zahteva najmanjše število oktetov, kar odpravi dve svoboščini, ki jih BER dovoljuje: nedoločeno obliko in oblazinjenje dolžine v dolgi obliki z vodilnimi ničelnimi okteti. Po tem pravilu ima dolžina vsebine pod 128 natanko en oktet dolžine, vsaka druga dolžina pa en začetni oktet plus natanko toliko naslednjih oktetov, kolikor pomembnih bajtov potrebuje dolžina. Klicatelj CmsHeaderStart že ima ContentLen, ker mu ga je ReadTlv pravkar vrnil, zato je dolžina glave izračunljiva, ne da bi pogledali en sam bajt vmesnega pomnilnika

Izpeljava naprej, ki jo jamči DER: dolžina vsebine 127 se zakodira kot A0 7F, 128 kot A0 81 80, 255 kot A0 81 FF, 256 kot A0 82 01 00 in 1500 kot A0 82 05 DC, zato dolžina glave sledi iz samega ContentLen, TryReadTlvAt pa je vsako neminimalno obliko BER že zavrnil
Vzorci, zgrajeni iz potrdil s 32 in 64 bajti, so ostali znotraj kratke oblike, kjer vzvratna hoja odgovori pravilno iz napačnega razloga, zato niz mejnih primerov zdaj prestopi 127, 128, 255 in 256 bajtov
// Dobavljeni pomožni element: glavo izpelje iz dolžine vsebine.
// Po X.690 10.1 so okteti dolžine funkcija ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // kratka oblika, X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // začetni oktet, X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // po eden na pomemben bajt
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

Dve podrobnosti naredita to varno in ne le verjetno. Prvič, predpostavko, da je vhod DER, uveljavlja že prejšnji sloj: TDerReader.TryReadTlvAt, na katerem je zgrajen ReadTlv, zavrne nedoločeno obliko, zavrne dolžino v dolgi obliki, katere prvi naslednji oktet je nič, in zavrne en sam naslednji oktet pod $80. TLV, ki pride do CmsSliceTlv, je te preglede že prestal, zato neminimalna dolžina v slogu BER ne more priti do izpeljave in je speljati na laž. Drugič, nadomestna rešitev za negativen rezultat zdaj varuje pravi odgovor in ne vmesne vrednosti. Velja povedati, da je bralnik odmik oznake poznal ves čas: TDerTlv nosi tako Offset kot HeaderLength, samo površina ReadTlv s štirimi izhodnimi parametri ju spusti. Vračanje teh vrednosti bi bil čistejši dolgoročni vmesnik; dobavljeni popravek to površino ohrani nedotaknjeno in pomožni element naredi pravilen po njegovih lastnih pravilih

Zakaj so preskusi časovnega žiga šli skozi s hroščem na mestu?

Ker je bilo vsako vzorčno potrdilo dovolj kratko za kratko obliko, vzvratna hoja pa je pravilna natanko v tem primeru. Tests.PadesTimestamp.pas svoje potrdilo podpisnika zgradi s SetLength(SignerCertDer, 32) v enem preskusu in s 64 v drugem, napolnjeno z naraščajočimi bajti. Nabor potrdil s 32 bajti se zakodira kot A0 20, tisti s 64 pa kot A0 40, vsak z enim samim oktetom dolžine. Vzvratna hoja od vsebine pristane na tem enem oktetu, njegov zgornji bit ni nastavljen, ker je prvi in edini oktet dolžine, in pomožni element odgovori pravilno iz napačnega razloga. Niz 1414 primerov je bil zelen, CMS s časovnim žigom se je razčlenil, preverjevalnik prve stopnje je prijavil B-T, in vsako od teh preverjanj se je izvajalo nad naborom potrdil, kakršnega ni vseboval še noben resničen dokument

Splošno pravilo je tisto koristno. Kadar koli je pot kode odvisna od tega, kako je dolžina zakodirana, mora vzorec prestopiti mejo kodiranja, pri DER pa to pomeni vsebino, daljšo od 127 bajtov, kar vsili dolgo obliko, in v idealnem primeru tudi daljšo od 255 bajtov, kar vsili še drugi naslednji oktet. Ista disciplina velja za drugi primer iz tistega pregleda, kjer samopreverjanje ni videlo odstopanja od DER: nerazvrščeni SET OF v signedAttrs je bil za obhod znotraj istega izvora neviden iz strukturno enakega razloga, preskus je preizkušal le vhode, pri katerih se napačna in pravilna koda strinjata. Spodnja skica pokliče pomožni element za rezanje neposredno, kar pomeni, da ga je treba za preskusno gradnjo izvoziti iz FPdfCms.pas; ista meja je dosegljiva prek javne površine tako, da BuildSignedData podate verižno potrdilo vsake velikosti in znova razčlenite rezultat s časovnim žigom

// Pripni mejo: izrezek skozi glavo v dolgi obliki se mora začeti pri oznaki
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;
      // vsebina se začne takoj za glavo; izrezek mora biti celoten 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;

Kje obnova še vedno potegne svoje meje

AddSignatureTimestampToCms je napisan za CMS, kakršnega odda BuildSignedData, in njegove meje izhajajo iz tega. Hoja pričakuje en sam SignerInfo in ponovno odda le tega, zato bi se tuj CMS z več podpisniki vrnil z enim podpisnikom; prepozna neobvezni nabor certificates [0], ne pa nabora crls [1], in CMS, ki ga nosi, glasno odpove z izjemo signerInfos SET expected in ne tiho z napačnim rezanjem. Novi unsignedAttrs nosi en atribut, zato je pravilo razvrščanja SET OF iz X.690 razdelek 11.6 trivialno izpolnjeno in ne potrebuje razvrščanja. Podpisani del pa ostane po zgradbi nedotaknjen: predpona SignerInfo do OCTET STRING podpisa se kopira dobesedno, zato preverjevalnik, ki znova zgošča signedAttrs, vidi enake bajte pred dodanim časovnim žigom in po njem. Kadar kljub temu dokument zavrne, so vzroki običajno drugje in si zaslužijo svoj kontrolni seznam

Bralnik DER, pisec, gradnik CMS in to vbrizgavanje časovnega žiga vsi izhajajo kot izvorna koda Pascal v komponenti PDFium za Delphi, in hrošč takšne oblike je argument za to: ko je obnovljeni SignedData devetdeset bajtov predolg, želite prebrati pomožni element, ki je rezal izrezek, in razdelek X.690, ki ga je narobe prebral, ne pa sledi sklada iz črne škatle