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