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
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
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
// 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