Odborný článok

PDFium: DER length octety nečítajte pozadu

PDFium Component lokalizuje začiatok vnorenej CMS štruktúry podľa dĺžky jej obsahu, nikdy nie prechodom pozadu cez length octety, pretože bajt tesne pred obsahom je posledný length octet a nehovorí nič o tom, koľko ich je pred ním. CmsHeaderStart v FPdfCms.pas namiesto toho odvodzuje dĺžku hlavičky z ContentLen, čo DER robí presným, a práve to bráni AddSignatureTimestampToCms poškodiť každý CMS, ktorého sada certifikátov je dlhšia než 127 bajtov

Ide o upgrade na PAdES B-T. Atribút signature-time-stamp, ten, ktorý ETSI EN 319 122-1 klauzula 5.3 definuje pod OID 1.2.840.113549.1.9.16.2.14, musí pristáť v unsignedAttrs prvku SignerInfo opísaného v RFC 5652 klauzule 5.3, a podľa definície sa dá pridať až po tom, čo existuje hodnota podpisu, pretože timestamp token sa počíta nad tou hodnotou. CMS je teda už postavený a už podpísaný, keď token dorazí. Pridanie jedného atribútu zmení dĺžku SignerInfo, čo zmení dĺžku SETu signerInfos, potom SignedData, potom EXPLICIT obalu [0] a napokon vonkajšieho ContentInfo. Každá obklopujúca hlavička sa musí emitovať znova a všetko, čo na tej ceste neleží, sa musí preniesť bajt po bajte. Walkthrough B-LT a B-LTA pokrýva, čo vám token prináša; tento článok je o tých štyroch bajtoch pred sadou certifikátov, ktoré rebuild stále dostával zle

Prečo pridanie timestampu potrebuje offset tagu súrodenca?

Pretože rebuild používa doslovne štyroch súrodencov SETu signerInfos a reader hlási, kde je ich obsah, nie kde je ich tag. TDerReader.ReadTlv vracia bajt tagu, offset obsahu, dĺžku obsahu a offset nasledujúceho TLV. To je správne rozhranie na zostupovanie do štruktúry, ale na skopírovanie celého elementu potrebujete octet, na ktorom sedí jeho tag, a volajúci drží len ContentOffs. CmsSliceTlv existuje, aby tú medzeru preklenul: keď mu dáte offset a dĺžku obsahu, vráti tag, length octety a obsah ako jeden buffer, a AddSignatureTimestampToCms ho volá pre OID contentType, INTEGER version, SET digestAlgorithms, SEQUENCE encapContentInfo a, keď je prítomná, aj pre množinu certificates [0]

// Vnútri AddSignatureTimestampToCms: zostúp a súrodencov vykroj doslovne
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;
// voliteľné 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 + length octety + obsah
  R.Position:= CN;
end;

Z tých piatich výrezov sú štyri drobné: jedenásťbajtový OID, trojbajtový INTEGER, sedemnásťbajtová sada digest algoritmov, trinásťbajtový detached encapContentInfo. Sada certifikátov je tá, ktorá nesie podpisový certifikát a jeho reťaz, a skutočný X.509 certifikát má prinajmenšom niekoľko stoviek bajtov. Sada certifikátov je preto jediný výrez, ktorého length octety niekedy prejdú do long formy, a práve ten výrez starý helper nedokázal lokalizovať

Čo rebuild pri pridávaní PAdES B-T timestampu v CMS prestavia: CmsSliceTlv kopíruje contentType, version, digestAlgorithms, encapContentInfo aj sadu certifikátov bajt po bajte, sada certifikátov je jediný výrez dosť dlhý na to, aby opustil short formu dĺžky, a každá obklopujúca hlavička od SignerInfo až po ContentInfo sa emituje znova
Podpísanej časti sa rebuild konštrukčne nedotkne, pretože prefix SignerInfo až po podpisový OCTET STRING sa kopíruje doslovne, takže validátor, ktorý znova hashuje signedAttrs, vidí identické bajty pred pristátím timestampu aj po ňom

Prečo sa DER length octety nedajú čítať pozadu?

Pretože počet length octetov je uložený v prvom z nich a pri čítaní od obsahu pozadu narazíte najprv na posledný. X.690 klauzula 8.1.3.4 definuje short formu: jeden octet, bit 8 nulový, bity 7 až 1 nesú dĺžku od 0 do 127. Klauzula 8.1.3.5 definuje long formu: počiatočný octet s nastaveným bitom 8, ktorého bity 7 až 1 dávajú počet nasledujúcich octetov, za nimi tie octety nesúce dĺžku ako unsigned big-endian integer. Nič v tom pravidle neoznačuje nasledujúci octet za nasledujúci. Jeho bit 8 je magnitúdový bit ako každý iný, takže prechod pozadu, ktorý testuje horný bit Buf[ContentOffs- 1], testuje dátový bit a potom číta jeho sedem nízkych bitov ako počet

// Starý helper, ktorému dali len offset obsahu
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // pristane na POSLEDNOM length octete
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // zmysluplné len pre PRVÝ
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

// Hlavička sady certifikátov s 1500 bajtmi:  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> bit 8 nastavený, $DC and $7F= 92
//   Result= ContentOffs- 94     (tag je na ContentOffs- 4)

Vezmite hlavičku sady certifikátov s 1500 bajtmi certifikátov, A0 82 05 DC. Prechod pristane na DC, vidí nastavený horný bit, vytiahne zo siedmich nízkych bitov 92 a nahlási tag 94 bajtov pred obsahom, hoci je 4 bajty pred ním. V SignedData postavenom cez BuildSignedData sedí obsah sady certifikátov len pár desiatok bajtov od začiatku CMS, takže vypočítaný offset nebol len skorý, ale záporný, a starý kód strážil ContentOffs- 1 proti pádu pod nulu, nie svoj konečný výsledok. CmsSliceTlv potom vzal výrez o deväťdesiat a viac bajtov dlhší než element, začínajúci pred bufferom, a prestavaný SignedData niesol ten výrez tam, kde mala byť jeho sada certifikátov. Trojoctetová dĺžka, ktorej posledný octet náhodou padol pod $80, povedzme A0 82 05 10, zlyhala opačne: prechod ju vzal za short-form octet a začal výrez na 05, dva bajty neskoro a vnútri length octetov, bez akéhokoľvek tagu. Výsledok bol zlý v oboch prípadoch, menil sa len smer

Prečo sa DER length octety nedajú čítať pozadu: čítanie A0 82 05 DC od konca pristane na poslednom octete DC, ktorého nastavený horný bit dá nezmyselný počet 92 a umiestni tag o 94 octetov skôr, kým A0 82 05 10 zlyhá opačne a začne výrez o dva octety neskoro vnútri length octetov
Starý CmsHeaderStart strážil medzivýsledok odčítania a nie svoj konečný výsledok, takže výrez mohol začať aj pred bufferom, a prestavaný SignedData niesol ten výrez tam, kam patrila jeho sada certifikátov

Čo DER garantuje, že je dopredné odvodenie presné?

DER garantuje, že kódovanie dĺžky je čistou funkciou dĺžky. X.690 klauzula 10.1 obmedzuje DER na definite formu a vyžaduje minimálny počet octetov, čím odstraňuje dve slobody, ktoré BER dovoľuje: indefinite formu a doplnenie long-form dĺžky vedúcimi nulovými octetmi. Podľa toho pravidla má dĺžka obsahu pod 128 presne jeden length octet a každá iná dĺžka má jeden počiatočný octet plus presne toľko nasledujúcich octetov, koľko potrebuje dĺžka na významové bajty. Volajúci CmsHeaderStart už ContentLen drží, pretože mu ho práve vrátil ReadTlv, takže dĺžka hlavičky je vypočítateľná bez toho, aby sa pozrel na jediný bajt buffera

Dopredné odvodenie, ktoré DER garantuje: dĺžka obsahu 127 sa zakóduje ako A0 7F, 128 ako A0 81 80, 255 ako A0 81 FF, 256 ako A0 82 01 00 a 1500 ako A0 82 05 DC, takže dĺžka hlavičky plynie len z ContentLen a TryReadTlvAt už odmietol každú neminimálnu BER formu
Fixture postavené z 32-bajtových a 64-bajtových certifikátov zostali vnútri short formy, kde prechod pozadu odpovedá správne z nesprávneho dôvodu, a preto boundary suite teraz prekračuje 127, 128, 255 a 256 bajtov
// Dodaný helper: odvoď hlavičku z dĺžky obsahu.
// Podľa X.690 10.1 sú length octety funkciou ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // short forma, X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // počiatočný octet, X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // jeden na významový bajt
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

Bezpečným a nie len plausibilným robia to dve veci. Po prvé, predpoklad, že vstup je DER, sa vynucuje vyššie v kóde: TDerReader.TryReadTlvAt, na ktorom stojí ReadTlv, odmieta indefinite formu, odmieta long-form dĺžku, ktorej prvý nasledujúci octet je nula, a odmieta jediný nasledujúci octet pod $80. TLV, ktoré dorazí do CmsSliceTlv, už tými kontrolami prešlo, takže BER neminimálna dĺžka sa k odvodeniu nemôže dostať a uviesť ho do omylu. Po druhé, fallback pre záporný výsledok teraz stráži skutočnú odpoveď, nie medzivýsledok. Stojí za to povedať, že reader offset tagu poznal celý čas: TDerTlv nesie Offset aj HeaderLength a zahadzuje ich len rozhranie ReadTlv so štyrmi out parametrami. Vracať ich by bolo čistejšie dlhodobé rozhranie; dodaná oprava necháva to rozhranie nedotknuté a robí helper správnym po jeho vlastných pravidlách

Prečo testy timestampu prešli, hoci bug v kóde bol?

Pretože každý fixture certifikát bol dosť krátky na short formu a prechod pozadu je správny presne v tom prípade. Tests.PadesTimestamp.pas stavia svoj podpisový certifikát cez SetLength(SignerCertDer, 32) v jednom teste a 64 v druhom, naplnený bajtovou rampou. Sada certifikátov s 32 bajtmi sa zakóduje ako A0 20 a tá so 64 bajtmi ako A0 40, každá s jediným length octetom. Prechod pozadu od obsahu pristane na tom jednom octete, jeho horný bit je nulový, pretože je prvý a jediný length octet, a helper odpovedá správne z nesprávneho dôvodu. Suite so 1414 prípadmi bola zelená, CMS s timestampom sa naparsoval, stage-1 validátor nahlásil B-T a každá z tých kontrol bežala proti sade certifikátov, akú nemal nikdy žiadny skutočný dokument

Všeobecné pravidlo je to užitočné. Vždy, keď kódová cesta závisí od toho, ako je dĺžka zakódovaná, fixture musí prekročiť hranicu kódovania, a pre DER to znamená obsah dlhší než 127 bajtov, čo vynúti long formu, a ideálne aj dlhší než 255 bajtov, čo vynúti druhý nasledujúci octet. Tá istá disciplína platí pre ten druhý prípad v tej revízii, kde self-verifikácia nemohla vidieť DER odchýlku: nezoradené SET OF v signedAttrs bolo pre same-origin round trip neviditeľné zo štrukturálne identického dôvodu — test precvičil len vstupy, na ktorých sa nesprávny kód a správny kód zhodujú. Náčrt nižšie volá slice helper priamo, čo znamená exportovať ho pre test build z FPdfCms.pas; tá istá hranica je dosiahnuteľná aj cez verejné rozhranie tak, že BuildSignedData dáte chain certifikát každej veľkosti a výsledok s timestampom znova naparsujete

// Pripni hranicu: výrez cez long-form hlavičku musí začať 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;
      // obsah začína hneď za hlavičkou; výrez musí byť celé 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;

Kde si rebuild stále drží hranice

AddSignatureTimestampToCms je napísaný pre CMS, ktorý emituje BuildSignedData, a jeho limity z toho plynú. Prechod očakáva jediný SignerInfo a znova emituje len ten, takže cudzí multi-signer CMS by sa vrátil s jedným podpisujúcim; rozpozná voliteľnú množinu certificates [0], ale nie množinu crls [1], a CMS, ktorý ju nesie, zlyhá nahlas s výnimkou signerInfos SET expected a nie ticho zlým vykrojením. Nové unsignedAttrs drží jeden atribút, takže pravidlo poradia SET OF z X.690 klauzuly 11.6 je splnené triviálne a nepotrebuje triedenie. A podpísanej časti sa konštrukčne nedotkne: prefix SignerInfo až po podpisový OCTET STRING sa kopíruje doslovne, a preto validátor, ktorý znova hashuje signedAttrs, vidí tie isté bajty pred pridaním timestampu aj po ňom. Keď dokument aj tak odmietne, príčiny bývajú inde a zaslúžia si vlastný checklist

DER reader, writer, CMS builder aj táto injekcia timestampu vychádzajú ako Pascal zdroj spolu s PDFium Delphi komponentom, a bug tohto tvaru je argumentom za to: keď prestavaný SignedData vyjde o deväťdesiat bajtov dlhší, chcete si prečítať helper, ktorý vykrojil ten výrez, a klauzulu X.690, ktorú prečítal zle, a nie stack trace z čiernej skrinky