PDFium Component pronalazi početak ugnježđene CMS strukture iz dužine njenog sadržaja, nikad hodanjem unazad preko okteta dužine, jer je bajt neposredno pre sadržaja poslednji oktet dužine i ne govori ništa o tome koliko ih prethodi. CmsHeaderStart u FPdfCms.pas izvodi dužinu zaglavlja iz ContentLen umesto toga, što DER čini egzaktnim, i to je ono što čuva AddSignatureTimestampToCms od kvarenja svakog CMS-a čiji je skup sertifikata duži od 127 bajtova
U pitanju je PAdES B-T nadogradnja. Atribut signature-time-stamp, onaj koji ETSI EN 319 122-1 klauzula 5.3 definiše pod OID-om 1.2.840.113549.1.9.16.2.14, mora da sleti u unsignedAttrs strukture SignerInfo opisane u RFC 5652 klauzula 5.3, a po definiciji može da se doda samo pošto vrednost potpisa postoji, jer se timestamp token računa nad tom vrednošću. Dakle CMS je već izgrađen i već potpisan kada token stigne. Dodavanje jednog atributa menja dužinu SignerInfo-a, što menja dužinu SET-a signerInfos, zatim SignedData, zatim EXPLICIT omotača [0], zatim spoljnog ContentInfo. Svako okružujuće zaglavlje mora biti ponovo emitovano, a sve što nije na toj putanji mora biti preneto bajt za bajt. Tekst o B-LT i B-LTA pokriva šta vam token donosi; ovaj članak je o četiri bajta ispred skupa sertifikata koje je rebuild uporno pogrešno pogađao
Zašto dodavanje timestamp-a zahteva tag offset brata?
Zato što rebuild ponovo koristi četiri brata SET-a signerInfos verbatim, a reader prijavljuje gde je njihov sadržaj, ne gde je njihov tag. TDerReader.ReadTlv vraća bajt taga, offset sadržaja, dužinu sadržaja i offset sledećeg TLV-a. To je prava površina za spuštanje u strukturu, ali da biste kopirali ceo element treba vam oktet na kojem mu stoji tag, a jedino što pozivalac drži je ContentOffs. CmsSliceTlv postoji da premosti tu prazninu: dat mu offset sadržaja i dužinu, vraća tag, oktete dužine i sadržaj kao jedan bafer, a AddSignatureTimestampToCms poziva ga za OID contentType, INTEGER version, SET digestAlgorithms, SEQUENCE encapContentInfo i, kada postoji, skup certificates [0]
// Unutar AddSignatureTimestampToCms: zaroni, iseci braću verbatim
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;
// opciona 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 + okteti dužine + sadržaj
R.Position:= CN;
end;
Od tih pet isečaka, četiri su sićušna: OID od jedanaest bajtova, INTEGER od tri bajta, skup algoritama sažimanja od sedamnaest bajtova, odvojeni encapContentInfo od trinaest bajtova. Skup sertifikata je onaj koji nosi sertifikat potpisnika i njegov lanac, a pravi X.509 sertifikat ide na nekoliko stotina bajtova u najmanju ruku. Skup sertifikata je zato jedini isečak čiji su okteti dužine ikad u dugoj formi, i to je isečak koji stari helper nije mogao da locira
Zašto se DER okteti dužine ne mogu čitati unazad?
Zato što je broj okteta dužine smešten u prvom od njih, a čitajući od sadržaja unazad prvo nailazite na poslednji. X.690 klauzula 8.1.3.4 definiše kratku formu: jedan oktet, bit 8 obrisan, bitovi 7 do 1 drže dužinu od 0 do 127. Klauzula 8.1.3.5 definiše dugu formu: početni oktet sa postavljenim bitom 8 čiji bitovi 7 do 1 daju broj narednih okteta, a zatim ti okteti nose dužinu kao neoznačen celi broj u big-endian obliku. Ništa u pravilu ne obeležava naredni oktet kao naredni. Njegov bit 8 je bit veličine kao i svaki drugi, pa hodanje unazad koje testira gornji bit Buf[ContentOffs- 1] testira bit podataka i zatim čita njegovih donjih sedam bitova kao broj
// Stari helper, kojem je dat samo offset sadržaja
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
P, LenByte, LongLen: Integer;
begin
P:= ContentOffs- 1; // sleće na POSLEDNJI oktet dužine
if P< 0 then
Exit(ContentOffs);
LenByte:= Buf[P];
if (LenByte and $80)= 0 then // ima smisla samo za PRVI
Result:= P- 1
else
begin
LongLen:= LenByte and $7F;
Result:= P- LongLen- 1;
end;
end;
// Zaglavlje skupa sertifikata od 1500 bajtova: A0 82 05 DC
// Buf[ContentOffs- 1]= $DC -> bit 8 postavljen, $DC and $7F= 92
// Result= ContentOffs- 94 (tag je na ContentOffs- 4)
Uzmite zaglavlje skupa sertifikata koji drži 1500 bajtova sertifikata, A0 82 05 DC. Hod sleće na DC, vidi postavljen gornji bit, izvlači 92 iz donjih sedam bitova i prijavljuje tag 94 bajta pre sadržaja, kada je 4 bajta pre njega. U SignedData koji gradi BuildSignedData, sadržaj skupa sertifikata stoji samo nekoliko desetina bajtova u CMS-u, pa izračunati offset nije bio samo preran nego negativan, a stari kod je štitio ContentOffs- 1 od pada ispod nule, ne svoj konačni rezultat. CmsSliceTlv je zatim uzeo isečak devedesetak bajtova duži od elementa, počevši pre bafera, i ponovo izgrađeni SignedData nosio je taj isečak tamo gde je trebalo da mu bude skup sertifikata. Dužina od tri okteta čiji poslednji oktet slučajno padne ispod $80, recimo A0 82 05 10, pala je na drugu stranu: hod ju je uzeo za oktet kratke forme i počeo isečak na 05, dva bajta prekasno i unutar okteta dužine, bez ikakvog taga. Ishod je bio pogrešan u oba slučaja, samo je smer varirao
Šta DER garantuje da izvođenje unapred bude egzaktno?
DER garantuje da je kodiranje dužine čista funkcija dužine. X.690 klauzula 10.1 ograničava DER na definitnu formu i zahteva najmanji broj okteta, što uklanja dve slobode koje BER dozvoljava: nedefinisanu formu, i dopunjavanje dužine u dugoj formi vodećim nula oktetima. Po tom pravilu dužina sadržaja ispod 128 ima tačno jedan oktet dužine, a svaka druga dužina ima jedan početni oktet plus tačno onoliko narednih okteta koliko dužina treba značajnih bajtova. Pozivalac CmsHeaderStart-a već drži ContentLen, jer ga je ReadTlv upravo vratio, pa je dužina zaglavlja izračunljiva bez pogleda na ijedan bajt bafera
// Isporučeni helper: izvedi zaglavlje iz dužine sadržaja.
// Po X.690 10.1 okteti dužine su funkcija ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
LengthOctets, Remaining: Integer;
begin
if ContentLen< 128 then
LengthOctets:= 1 // kratka forma, X.690 8.1.3.4
else
begin
LengthOctets:= 1; // početni oktet, X.690 8.1.3.5
Remaining:= ContentLen;
while Remaining> 0 do
begin
Inc(LengthOctets); // po jedan za svaki značajan bajt
Remaining:= Remaining shr 8;
end;
end;
Result:= ContentOffs- LengthOctets- 1;
if Result< 0 then
Result:= ContentOffs;
end;
Dva detalja čine ovo bezbednim a ne samo verovatnim. Prvo, pretpostavka da je ulaz DER sprovodi se uzvodno: TDerReader.TryReadTlvAt, na kojem je ReadTlv izgrađen, odbija nedefinisanu formu, odbija dužinu u dugoj formi čiji je prvi naredni oktet nula, i odbija jedan naredni oktet ispod $80. TLV koji stigne do CmsSliceTlv-a već je prošao te provere, pa ne-minimalna dužina u BER stilu ne može stići do izvođenja i naterati ga da slaže. Drugo, fallback za negativan rezultat sada štiti pravi odgovor, a ne međurezultat. Vredi reći da je reader oduvek znao offset taga: TDerTlv nosi i Offset i HeaderLength, i samo ih površina ReadTlv sa četiri izlazna parametra odbacuje. Njihovo vraćanje bilo bi čistiji dugoročni interfejs; isporučena ispravka zadržava tu površinu netaknutom i čini helper ispravnim po sopstvenim uslovima
Zašto su timestamp testovi prolazili sa bugom na mestu?
Zato što je svaki fikstura sertifikat bio dovoljno kratak da koristi kratku formu, a hodanje unazad je ispravno tačno za taj slučaj. Tests.PadesTimestamp.pas gradi svoj sertifikat potpisnika sa SetLength(SignerCertDer, 32) u jednom testu i 64 u drugom, ispunjen bajt rampom. Skup sertifikata od 32 bajta kodira se kao A0 20, a onaj od 64 bajta kao A0 40, svaki sa jednim oktetom dužine. Hodanje unazad od sadržaja sleće na taj jedan oktet, njegov gornji bit je obrisan jer je prvi i jedini oktet dužine, i helper daje tačan odgovor iz pogrešnog razloga. Suite od 1414 slučajeva bio je zelen, CMS sa timestamp-om se parsirao, validator prve faze prijavljivao je B-T, i svaka od tih provera izvršena je nad skupom sertifikata koji nijedan pravi dokument nikad nije sadržao
Opšte pravilo je koristan deo. Kad god putanja koda zavisi od toga kako je dužina kodirana, fikstura mora da pređe granicu kodiranja, a za DER to znači sadržaj duži od 127 bajtova, što forsira dugu formu, i po mogućstvu duži i od 255 bajtova, što forsira drugi naredni oktet. Ista disciplina važi za drugi slučaj iz tog pregleda gde samoverifikacija nije mogla da vidi DER odstupanje: nesortirani SET OF u signedAttrs bio je nevidljiv za round trip unutar istog izvora iz strukturno identičnog razloga, test je vežbao samo ulaze na kojima se pogrešan kod i ispravan kod slažu. Skica ispod poziva slice helper direktno, što znači izvoz tog helpera iz FPdfCms.pas za test build; ista granica je dohvatljiva i kroz javnu površinu ako BuildSignedData-u date lanac sa sertifikatom svake veličine i ponovo parsirate timestampovani rezultat
// Prikuj granicu: isečak kroz zaglavlje duge forme mora početi 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;
// sadržaj počinje odmah posle zaglavlja; isečak mora biti ceo 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;
Gde rebuild i dalje povlači svoje granice
AddSignatureTimestampToCms je napisan za CMS koji emituje BuildSignedData, i njegove granice iz toga slede. Hod očekuje jedan SignerInfo i ponovo emituje samo njega, pa bi strani CMS sa više potpisnika vratio jedan potpis; prepoznaje opcioni skup certificates [0] ali ne i skup crls [1], i CMS koji ga nosi pada glasno uz izuzetak signerInfos SET expected a ne tiho pogrešnim isečkom. Novi unsignedAttrs drži jedan atribut, pa je pravilo redosleda SET OF iz X.690 klauzula 11.6 trivijalno zadovoljeno i ne traži sortiranje. A potpisani deo je netaknut po konstrukciji: prefiks SignerInfo-a do potpisnog OCTET STRING-a kopira se verbatim, i zato validator koji ponovo sažima signedAttrs vidi iste bajtove pre i posle dodavanja timestamp-a. Kada neko i pored toga odbije dokument, uzroci su obično drugde i vredi ih zasebno popisati
DER reader, pisac, CMS builder i ovo ubacivanje timestamp-a svi se isporučuju kao Pascal izvorni kod uz PDFium Delphi komponentu, i bug ovakvog oblika je argument za to: kada ponovo izgrađeni SignedData izađe devedeset bajtova predugačak, želite da čitate helper koji je isekao isečak i klauzulu X.690 koju je pogrešno pročitao, a ne stack trace iz crne kutije