Technický článek

Nikdy neprocházejte oktety délky DER pozpátku: PDFium

PDFium Component lokalizuje začátek vnořené struktury CMS z délky jejího obsahu, nikdy procházením pozpátku přes oktety délky, protože bajt těsně před obsahem je poslední oktet délky a nic neříká o tom, kolik jich předchází. CmsHeaderStart v FPdfCms.pas odvozuje délku hlavičky z ContentLen, což DER dělá exaktním, a to je to, co brání AddSignatureTimestampToCms pokazit každé CMS, jehož sada certifikátů je delší než 127 bajtů

Prostředí je upgrade PAdES B-T. Atribut signature-time-stamp, ten, který ETSI EN 319 122-1 bod 5.3 definuje pod OID 1.2.840.113549.1.9.16.2.14, musí přistát v unsignedAttrs SignerInfo popsaného v RFC 5652 bod 5.3 a z definice se dá přidat až poté, co existuje hodnota podpisu, protože timestamp token se počítá nad tou hodnotou. CMS je tedy už postavené a už podepsané, když token přijde. Přidání jednoho atributu mění délku SignerInfo, což mění délku SETu signerInfos, pak SignedData, pak obálky [0] EXPLICIT a pak vnějšího ContentInfo. Každá obklopující hlavička se musí emitovat znovu a všechno, co na té cestě není, se musí přenést bajt od bajtu. Průvodce B-LT a B-LTA rozebírá, co token vynáší; tenhle článek je o čtyřech bajtech před sadou certifikátů, které rebuild pořád trefil špatně

Proč přidání timestampu potřebuje tag offset sourozence?

Protože rebuild znovu používá čtyři sourozence SETu signerInfos slovo od slova a reader hlásí, kde je jejich obsah, ne kde je jejich tag. TDerReader.ReadTlv podává zpět tag bajt, offset obsahu, délku obsahu a offset další TLV. To je správný povrch pro sestup do struktury, ale ke zkopírování celého prvku potřebujete oktet, kde sedí jeho tag, a jediné, co volající drží, je ContentOffs. CmsSliceTlv existuje, aby tu mezeru přemostil: pro daný offset a délku obsahu vrací tag, oktety délky a obsah jako jeden buffer a AddSignatureTimestampToCms ho volá pro OID contentType, INTEGER version, SET digestAlgorithms, SEQUENCE encapContentInfo a, je-li přítomná, sadu certificates [0]

// Uvnitř AddSignatureTimestampToCms: sestup, vykrájej sourozence slovo od slova
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;
// volitelné 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 + oktety délky + obsah
  R.Position:= CN;
end;

Z těch pěti výseků jsou čtyři drobné: jedenáctibajtové OID, tříbajtový INTEGER, sedmnáctibajtová sada digest algoritmů, třináctibajtové odpojené encapContentInfo. Sada certifikátů je ta, která nese certifikát podepisujícího a jeho řetězec a reálný certifikát X.509 má aspoň několik set bajtů. Sada certifikátů je proto jediný výsek, jehož oktety délky kdy stoupnou do dlouhé formy, a je to výsek, který starý helper nedokázal najít

Co přidání timestampu PAdES B-T v CMS přestavuje: CmsSliceTlv kopíruje contentType, version, digestAlgorithms, encapContentInfo i sadu certifikátů bajt od bajtu, sada certifikátů je jediný výsek dost dlouhý na opuštění krátké formy délky a každá obklopující hlavička od SignerInfo po ContentInfo se emituje znovu
Podepsaná část zůstává konstrukcí nedotčená, protože prefix SignerInfo až po OCTET STRING podpisu se kopíruje slovo od slova, takže validátor, který znovu digestuje signedAttrs, vidí identické bajty před i poté, co timestamp přistane

Proč nelze oktety délky DER procházet pozpátku?

Protože počet oktetů délky je uložený v prvním z nich a při čtení od obsahu pozpátku potkáte nejdřív poslední. X.690 bod 8.1.3.4 definuje krátkou formu: jeden oktet, bit 8 v nule, bity 7 až 1 nesoucí délku 0 až 127. Bod 8.1.3.5 definuje dlouhou formu: úvodní oktet s nastaveným bitem 8, jehož bity 7 až 1 dávají počet následných oktetů, následovaný těmi oktety nesoucími délku jako bezznaménkové big-endian celé číslo. Nic v pravidle neoznačuje následný oktet jako následný. Jeho bit 8 je magnitudový bit jako každý jiný, takže pozpátková procházka testující horní bit Buf[ContentOffs- 1] testuje datový bit a pak čte jeho spodních sedm bitů jako počet

// Starý helper, kterému byl dán jen offset obsahu
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // přistane na POSLEDNÍM oktetu délky
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // smysluplné jen pro PRVNÍ z nich
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

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

Vezměte hlavičku sady certifikátů držící 1500 bajtů certifikátů, A0 82 05 DC. Procházka přistane na DC, uvidí nastavený horní bit, vytáhne 92 ze spodních sedmi bitů a hlásí tag 94 bajtů před obsahem, když je 4 bajty před ním. V SignedData postaveném BuildSignedData sedí obsah sady certifikátů jen pár desítek bajtů do CMS, takže spočtený offset nebyl jen předčasný, ale záporný a starý kód hlídal ContentOffs- 1 proti klesnutí pod nulu, ne svůj finální výsledek. CmsSliceTlv pak vzal výsek o devedeset a kus bajtů delší než prvek, začínající před bufferem, a přestavěné SignedData neslo ten výsek tam, kde měla být jeho sada certifikátů. Tříoktetová délka, jejíž poslední oktet náhodou spadl pod $80, řekněme A0 82 05 10, selhala naopak: procházka ji brala za krátkoformový oktet a začala výsek na 05, dva bajty pozdě a uvnitř oktetů délky, bez tagu úplně. Výsledek byl špatný oběma způsoby, měnil se jen směr

Proč nelze oktety délky DER procházet pozpátku: čtení A0 82 05 DC od konce přistane na posledním oktetu DC, jehož nastavený horní bit dá falešný počet 92 a umístí tag 94 oktetů předčasně, zatímco A0 82 05 10 selže opačně a začne výsek dva oktety pozdě uvnitř oktetů délky
Staré CmsHeaderStart hlídalo mezisoučet místo finálního výsledku, takže výsek mohl začít i před bufferem a přestavěné SignedData neslo ten výsek tam, kam patřila jeho sada certifikátů

Co DER garantuje, že dělá dopřednou derivaci exaktní?

DER garantuje, že kódování délky je čistá funkce délky. X.690 bod 10.1 omezuje DER na určitou formu a vyžaduje minimální počet oktetů, což odstraňuje dvě svobody, které BER dovoluje: neurčitou formu a vypodkládání dlouhoformové délky úvodními nulovými oktety. Pod tím pravidlem má délka obsahu pod 128 přesně jeden oktet délky a jakákoli jiná délka má jeden úvodní oktet plus přesně tolik následných oktetů, kolik délka potřebuje významných bajtů. Volající CmsHeaderStart už drží ContentLen, protože mu ho ReadTlv právě vrátila, takže délka hlavičky je spočitatelná bez pohledu na jediný bajt bufferu

Dopředná derivace, kterou DER garantuje: délka obsahu 127 se kóduje jako A0 7F, 128 jako A0 81 80, 255 jako A0 81 FF, 256 jako A0 82 01 00 a 1500 jako A0 82 05 DC, takže délka hlavičky plyne jen z ContentLen a TryReadTlvAt už odmítla každou neminimální formu BER
Fixture postavené z 32bajtových a 64bajtových certifikátů zůstávaly uvnitř krátké formy, kde pozpátková procházka odpovídá správně pro špatný důvod, proto hraniční sada teď překračuje 127, 128, 255 i 256 bajtů
// Dodaný helper: odvoď hlavičku z délky obsahu.
// Pod X.690 10.1 jsou oktety délky funkcí ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // krátká forma, X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // úvodní oktet, X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // jeden na významný bajt
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

Dva detaily z toho dělají bezpečné, ne jen věrohodné. Za prvé, předpoklad, že vstup je DER, se vynucuje proti proudu: TDerReader.TryReadTlvAt, na kterém ReadTlv stojí, odmítá neurčitou formu, odmítá dlouhoformovou délku, jejíž první následný oktet je nula, a odmítá jediný následný oktet pod $80. TLV, která dorazí do CmsSliceTlv, ty kontroly už prošla, takže BER-style neminimální délka se k derivaci nedostane a nemůže ji přimět lhát. Za druhé, záloha pro záporný výsledek teď hlídá reálnou odpověď, ne mezisoučet. Stojí za zmínku, že reader tag offset znal celou dobu: TDerTlv nese i Offset i HeaderLength a jen čtyřvýstupový parametrický povrch ReadTlv je upouští. Vracet je by bylo čistší dlouhodobé rozhraní; dodaná oprava drží ten povrch intaktní a dělá helper správným na vlastních termínech

Proč testy timestampu prošly, když bug seděl na místě?

Protože každý fixture certifikát byl dost krátký na krátkou formu a pozpátková procházka je správná přesně pro tenhle případ. Tests.PadesTimestamp.pas staví certifikát podepisujícího s SetLength(SignerCertDer, 32) v jednom testu a 64 v jiném, vyplněné bajtovou rampou. 32bajtová sada certifikátů se kóduje jako A0 20 a 64bajtová jako A0 40, pokaždé jediný oktet délky. Procházka pozpátku od obsahu přistane na tom jednom oktetu, jeho horní bit je v nule, protože je to první a jediný oktet délky, a helper odpovídá správně pro špatný důvod. Sada 1414 případů byla zelená, timestampované CMS se parsovalo, validátor fáze 1 hlásil B-T a každou z těch kontrol provázala sada certifikátů, kterou žádný reálný dokument nikdy neobsáhl

Obecné pravidlo je ta užitečná část. Kdykoli závisí kódová cesta na tom, jak je délka kódovaná, musí fixture překročit kódovací hranici a u DER to znamená obsah delší než 127 bajtů, což vynutí dlouhou formu, a ideálně i delší než 255 bajtů, což vynutí druhý následný oktet. Tatáž disciplína platí pro druhý případ v tom přezkumu, kde se sebeověření nemohlo vidět na odchylku DER: neřazený SET OF v signedAttrs byl neviditelný pro round trip stejného původu ze strukturálně identického důvodu, test provatzoval jen vstupy, na kterých se špatný kód a správný kód shodují. Skicář níže volá slice helper přímo, což znamená exportovat ho z FPdfCms.pas pro testovací build; stejná hranice je dosažitelná přes veřejný povrch podáním certifikátu řetězce každé velikosti do BuildSignedData a znovuparsováním timestampovaného výsledku

// Přibij hranici: výsek skrz dlouhoformovou hlavičku musí začínat 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číná hned za hlavičkou; výsek musí být 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 pořád kreslí své hranice

AddSignatureTimestampToCms je psané pro CMS, které emituje BuildSignedData, a jeho limity z toho plynou. Procházka čeká jediný SignerInfo a emituje znovu jen ten, takže cizí multi-signer CMS by se vrátilo s jedním podepisujícím; rozpozná volitelnou sadu certificates [0], ale ne sadu crls [1] a CMS, které jednu nese, selže hlasitě s výjimkou signerInfos SET expected místo tichého špatného vykrájení. Nové unsignedAttrs drží jeden atribut, takže pravidlo řazení SET OF z X.690 bod 11.6 se splní triviálně a sort nepotřebuje. A podepsaná část zůstává konstrukcí nedotčená: prefix SignerInfo až po OCTET STRING podpisu se kopíruje slovo od slova, proto validátor, který znovu digestuje signedAttrs, vidí tytéž bajty před i po přidání timestampu. Když přesto dokument odmítne, příčiny bývají jinde a stojí za vlastní checklist

DER reader, writer, stavitel CMS i tahle injekce timestampu všechny se dodávají jako Pascal zdroják s PDFium Delphi komponentou a bug tohohle tvaru je argument pro to: když přestavěné SignedData vyjde o devedeset bajtů delší, chcete číst helper, který výsek vykrájel, a bod X.690, který špatně přečetl, ne stack trace z černé skříňky