Tekninen artikkeli

Miksi DER-pituustavuja ei voi kävellä takaperin

PDFium Component paikantaa sisäkkäisen CMS-rakenteen alun sen sisällön pituudesta eikä koskaan kävelemällä takaperin pituustavujen yli, koska sisältöä välittömästi edeltävä tavu on viimeinen pituustavu eikä kerro mitään siitä, kuinka monta niitä edeltää. FPdfCms.pas-tiedoston CmsHeaderStart johtaa otsakkeen pituuden sen sijaan ContentLen-arvosta, minkä DER tekee tarkaksi, ja juuri se estää AddSignatureTimestampToCms-funktiota turmelemasta jokaista CMS-rakennetta, jonka varmenneryhmä on yli 127 tavua pitkä

Kyseessä on PAdES B-T -päivitys. Allekirjoitusaikaleima-attribuutin, jonka ETSI EN 319 122-1 kohta 5.3 määrittelee OID:n 1.2.840.113549.1.9.16.2.14 alle, on päädyttävä RFC 5652 kohdassa 5.3 kuvatun SignerInfo-rakenteen unsignedAttrs-joukkoon, ja määritelmänsä mukaan se voidaan lisätä vain sen jälkeen kun allekirjoituksen arvo on olemassa, koska aikaleimatoken lasketaan tuon arvon yli. CMS on siis jo rakennettu ja jo allekirjoitettu, kun token saapuu. Yhden attribuutin lisääminen muuttaa SignerInfo-rakenteen pituutta, mikä muuttaa signerInfos-SET-rakenteen pituutta, sitten SignedData-rakenteen, sitten [0]-EXPLICIT-kääreen ja lopulta ulomman ContentInfo-rakenteen. Jokainen ympäröivä otsake on tuotettava uudelleen, ja kaikki mikä ei ole tuolla polulla on siirrettävä yli tavu tavulta. B-LT- ja B-LTA-läpikäynti kertoo, mitä token ostaa; tämä artikkeli kertoo niistä neljästä tavusta varmenneryhmän edessä, jotka uudelleenrakennus sai yhä väärin

Miksi aikaleiman lisääminen tarvitsee sisaruksen tagin siirtymän?

Koska uudelleenrakennus käyttää uudelleen neljää signerInfos-SET-rakenteen sisarusta sellaisenaan, ja lukija kertoo, missä niiden sisältö on, ei missä niiden tagi on. TDerReader.ReadTlv palauttaa tagitavun, sisällön siirtymän, sisällön pituuden ja seuraavan TLV:n siirtymän. Se on oikea rajapinta rakenteeseen laskeutumiseen, mutta kokonaisen elementin kopioimiseen tarvitaan se oktetti, jossa sen tagi istuu, ja ainoa mitä kutsujalla on hallussaan, on ContentOffs. CmsSliceTlv on olemassa tuon kuilun ylittämiseksi: annettuna sisällön siirtymä ja pituus se palauttaa tagin, pituustavut ja sisällön yhtenä puskurina, ja AddSignatureTimestampToCms kutsuu sitä contentType-OID:lle, version-INTEGERille, digestAlgorithms-SET-rakenteelle, encapContentInfo-SEQUENCE-rakenteelle ja, kun se on läsnä, certificates [0] -joukolle

// AddSignatureTimestampToCms-funktion sisällä: laskeudu, leikkaa sisarukset sellaisenaan
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;
// valinnainen 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);   // tagi + pituustavut + sisältö
  R.Position:= CN;
end;

Noista viidestä leikkeestä neljä ovat pikkuruisia: yksitoistatavuinen OID, kolmetavuinen INTEGER, seitsemäntoistatavuinen tiivistysalgoritmijoukko, kolmetoistatavuinen irrallinen encapContentInfo. Varmenneryhmä on se, joka kantaa allekirjoittajan varmenteen ja sen ketjun, ja todellinen X.509-varmenne yltää vähintään useisiin satoihin tavuihin. Varmenneryhmä on siksi ainoa leike, jonka pituustavut ovat koskaan pitkässä muodossa, ja juuri sitä leikettä vanha apufunktio ei kyennyt paikantamaan

Mitä PAdES B-T -aikaleiman lisääminen rakentaa uudelleen CMS-rakenteessa: CmsSliceTlv kopioi contentType-, version-, digestAlgorithms-, encapContentInfo-rakenteet ja varmenneryhmän tavu tavulta, varmenneryhmä on ainoa leike joka on tarpeeksi pitkä poistuakseen lyhyestä pituusmuodosta, ja jokainen ympäröivä otsake SignerInfo-rakenteesta ContentInfo-rakenteeseen asti tuotetaan uudelleen
Allekirjoitettu osa jää rakenteellisesti koskemattomaksi, koska SignerInfo-etuliite allekirjoituksen OCTET STRING -rakenteeseen asti kopioidaan sellaisenaan, joten validaattori, joka tiivistää signedAttrs-uudelleen, näkee identtiset tavut ennen aikaleiman laskeutumista ja sen jälkeen

Miksi DER-pituustavuja ei voi kävellä takaperin?

Koska pituustavujen lukumäärä on tallennettu niistä ensimmäiseen, ja sisällöstä taaksepäin luettaessa vastaan tulee viimeinen ensin. X.690 kohta 8.1.3.4 määrittelee lyhyen muodon: yksi oktetti, bitti 8 nolla, bitit 7–1 kantavat pituuden väliltä 0–127. Kohta 8.1.3.5 määrittelee pitkän muodon: aloitusoktetti, jonka bitti 8 on asetettu ja jonka bitit 7–1 antavat seuraavien oktettien lukumäärän, jota seuraavat nuo oktetit kantavat pituuden etumerkittömänä big-endian-kokonaislukuna. Mikään säännössä ei merkitse seuraavaa oktettia seuraavaksi. Sen bitti 8 on suuruusbitti siinä missä muutkin, joten takaperin kävely, joka testaa Buf[ContentOffs- 1]-tavun ylintä bittiä, testaa databittiä ja lukee sitten sen seitsemän alinta bittiä lukumääränä

// Vanha apufunktio, jolle annettiin vain sisällön siirtymä
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // osuu VIIMEISEEN pituustavuun
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // mielekäs vain ENSIMMÄISELLE
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

// 1500-tavuisen varmenneryhmän otsake:  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> bitti 8 asetettu, $DC and $7F= 92
//   Result= ContentOffs- 94     (tagi on kohdassa ContentOffs- 4)

Otetaan 1500 tavua varmenteita sisältävän varmenneryhmän otsake, A0 82 05 DC. Kävely osuu tavuun DC, näkee asetetun ylimmän bitin, poimii seitsemästä alimmasta bitistä luvun 92 ja raportoi tagin olevan 94 tavua ennen sisältöä, kun se on 4 tavua ennen sitä. BuildSignedData-funktion rakentamassa SignedData-rakenteessa varmenneryhmän sisältö istuu vain muutaman kymmenen tavun päässä CMS:n alusta, joten laskettu siirtymä ei ollut ainoastaan liian aikainen vaan negatiivinen, ja vanha koodi vartioi ContentOffs- 1-lauseketta nollan alapuolelle painumiselta, ei lopputulostaan. CmsSliceTlv otti sitten yhdeksänkymmentäjotain tavua elementtiä pidemmän leikkeen, joka alkoi ennen puskuria, ja uudelleenrakennettu SignedData kantoi tuota leikettä siinä missä sen varmenneryhmän olisi pitänyt olla. Kolmitavuinen pituus, jonka viimeinen tavu sattui putoamaan alle arvon $80, vaikkapa A0 82 05 10, epäonnistui toisin päin: kävely piti sitä lyhyen muodon oktettina ja aloitti leikkeen kohdasta 05, kaksi tavua liian myöhään ja pituustavujen sisällä, ilman tagia lainkaan. Lopputulos oli väärä kummallakin tavalla, vain suunta vaihteli

Miksi DER-pituustavuja ei voi kävellä takaperin: kun A0 82 05 DC luetaan lopusta, kävely osuu viimeiseen oktetiin DC, jonka asetettu ylin bitti antaa virheellisen lukumäärän 92 ja sijoittaa tagin 94 oktettia liian aikaisin, kun taas A0 82 05 10 epäonnistuu toisin päin ja aloittaa leikkeen kaksi oktettia liian myöhään pituustavujen sisällä
Vanha CmsHeaderStart vartioi välivaiheen vähennyslaskua eikä sen lopputulosta, joten leike saattoi alkaa jopa ennen puskuria, ja uudelleen rakennettu SignedData kantoi tuota leikettä siinä missä sen varmenneryhmän olisi pitänyt olla

Mitä DER takaa, mikä tekee eteenpäin johtamisesta tarkan?

DER takaa, että pituuden koodaus on puhtaasti pituuden funktio. X.690 kohta 10.1 rajoittaa DER:n määrättyyn muotoon ja vaatii vähimmäismäärän oktetteja, mikä poistaa ne kaksi vapautta, jotka BER sallii: määräämättömän muodon ja pitkän muodon pituuden täyttämisen etunollilla. Tuon säännön alla alle 128:n sisältöpituudella on täsmälleen yksi pituustavu, ja millä tahansa muulla pituudella on yksi aloitusoktetti plus täsmälleen niin monta seuraavaa oktettia kuin pituus tarvitsee merkitseviä tavuja. CmsHeaderStart-funktion kutsujalla on jo ContentLen hallussaan, koska ReadTlv juuri palautti sen, joten otsakkeen pituus on laskettavissa katsomatta puskurin yhtäkään tavua

Eteenpäin johtaminen, jonka DER takaa: sisältöpituus 127 koodautuu muotoon A0 7F, 128 muotoon A0 81 80, 255 muotoon A0 81 FF, 256 muotoon A0 82 01 00 ja 1500 muotoon A0 82 05 DC, joten otsakkeen pituus seuraa pelkästä ContentLen-arvosta ja TryReadTlvAt on jo hylännyt jokaisen ei-minimaalisen BER-muodon
32- ja 64-tavuisista varmenteista rakennetut testiaineistot pysyivät lyhyen muodon sisällä, jossa takaperin kävely vastaa oikein väärästä syystä, minkä takia rajasarja ylittää nyt 127, 128, 255 ja 256 tavua
// Toimitettu apufunktio: johda otsake sisällön pituudesta.
// X.690 10.1:n mukaan pituustavut ovat ContentLen-arvon funktio
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // lyhyt muoto, X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // aloitusoktetti, X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // yksi per merkitsevä tavu
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

Kaksi yksityiskohtaa tekevät tästä turvallista eikä pelkästään uskottavaa. Ensinnäkin oletus siitä, että syöte on DER:ää, valvotaan ylempänä: TDerReader.TryReadTlvAt, jonka varaan ReadTlv rakentuu, hylkää määräämättömän muodon, hylkää pitkän muodon pituuden, jonka ensimmäinen seuraava oktetti on nolla, ja hylkää yksittäisen alle $80 olevan seuraavan oktetin. CmsSliceTlv-funktioon asti päätynyt TLV on jo läpäissyt nuo tarkistukset, joten BER-tyylinen ei-minimaalinen pituus ei voi päästä johtamiseen asti ja saada sitä valehtelemaan. Toiseksi negatiivisen tuloksen varareitti vartioi nyt todellista vastausta eikä välivaihetta. Kannattaa sanoa, että lukija tiesi tagin siirtymän koko ajan: TDerTlv kantaa sekä Offset- että HeaderLength-kenttää, ja vain neljän ulostuloparametrin ReadTlv-rajapinta pudottaa ne. Niiden palauttaminen olisi siistimpi pitkän aikavälin rajapinta; toimitettu korjaus pitää tuon rajapinnan ennallaan ja tekee apufunktiosta oikean omin ehdoin

Miksi aikaleimatestit menivät läpi vian ollessa paikallaan?

Koska jokainen testiaineiston varmenne oli tarpeeksi lyhyt käyttämään lyhyttä muotoa, ja takaperin kävely on oikeassa täsmälleen siinä tapauksessa. Tests.PadesTimestamp.pas rakentaa allekirjoittajavarmanteensa komennolla SetLength(SignerCertDer, 32) yhdessä testissä ja arvolla 64 toisessa, täytettynä tavurampilla. 32-tavuinen varmenneryhmä koodautuu muotoon A0 20 ja 64-tavuinen muotoon A0 40, kumpikin yhdellä pituustavulla. Sisällöstä takaperin käveleminen osuu tuohon yhteen tavuun, sen ylin bitti on nolla koska se on ensimmäinen ja ainoa pituustavu, ja apufunktio vastaa oikein väärästä syystä. 1414 tapauksen sarja oli vihreä, aikaleimattu CMS jäsentyi, vaiheen 1 validaattori raportoi B-T:n, ja jokainen noista tarkistuksista ajettiin varmenneryhmää vasten, jollaista yhdessäkään oikeassa asiakirjassa ei ole koskaan ollut

Yleinen sääntö on se hyödyllinen osa. Aina kun koodipolku riippuu siitä, miten pituus on koodattu, testiaineiston on ylitettävä koodausraja, ja DER:n kohdalla se tarkoittaa yli 127 tavun sisältöä, mikä pakottaa pitkän muodon, ja ihanteellisesti myös yli 255 tavua, mikä pakottaa toisen seuraavan oktetin. Sama kurinalaisuus pätee siihen toiseen tapaukseen tuossa katselmoinnissa, jossa itsevarmennus ei nähnyt DER-poikkeamaa: lajittelematon SET OF signedAttrs-rakenteessa oli näkymätön samaan lähteeseen nojaavalle edestakaiselle kierrokselle rakenteellisesti identtisestä syystä, testi harjoitti vain syötteitä, joilla väärä koodi ja oikea koodi ovat yhtä mieltä. Alla oleva luonnos kutsuu leikeapufunktiota suoraan, mikä tarkoittaa sen viemistä FPdfCms.pas-tiedostosta testibuildia varten; sama raja on tavoitettavissa julkisen rajapinnan kautta antamalla BuildSignedData-funktiolle kunkin kokoinen ketjuvarmenteen ja jäsentämällä aikaleimattu tulos uudelleen

// Kiinnitä raja: leikkeen pitkän muodon otsakkeen läpi on alettava tagista
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;
      // sisältö alkaa heti otsakkeen jälkeen; leikkeen on oltava koko 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;

Mihin uudelleenrakennus vetää yhä rajansa

AddSignatureTimestampToCms on kirjoitettu CMS:lle, jonka BuildSignedData tuottaa, ja sen rajat seuraavat siitä. Kävely odottaa yhtä SignerInfo-rakennetta ja tuottaa uudelleen vain sen, joten vieras monen allekirjoittajan CMS palaisi yhdellä allekirjoittajalla; se tunnistaa valinnaisen certificates [0] -joukon muttei crls [1] -joukkoa, ja sellaisen kantava CMS epäonnistuu äänekkäästi signerInfos SET expected -poikkeuksella sen sijaan, että leikkaisi hiljaisesti väärin. Uusi unsignedAttrs sisältää yhden attribuutin, joten X.690 kohdan 11.6 SET OF -järjestyssääntö täyttyy triviaalisti eikä vaadi lajittelua. Ja allekirjoitettu osa jää rakenteellisesti koskemattomaksi: SignerInfo-etuliite allekirjoituksen OCTET STRING -rakenteeseen asti kopioidaan sellaisenaan, minkä takia validaattori, joka tiivistää signedAttrs-rakenteen uudelleen, näkee samat tavut ennen aikaleiman lisäämistä ja sen jälkeen. Kun jokin silti hylkää asiakirjan, syyt ovat yleensä muualla ja ansaitsevat oman tarkistuslistansa

DER-lukija, kirjoittaja, CMS-rakentaja ja tämä aikaleiman syöttö toimitetaan kaikki Pascal-lähdekoodina PDFium Delphi -komponentin mukana, ja tämänmuotoinen bugi on argumentti sen puolesta: kun uudelleenrakennettu SignedData tulee ulos yhdeksänkymmentä tavua liian pitkänä, haluat lukea sen apufunktion, joka leikkasi leikkeen, ja sen X.690:n kohdan, jonka se luki väärin, et pinon jäljitystä mustasta laatikosta