PDFium Component locira početak ugniježđene CMS strukture iz duljine njenog sadržaja, nikad hodanjem unatrag preko length octeta, jer je bajt neposredno prije sadržaja zadnji length octet i ne govori ništa o tome koliko ih prethodi. CmsHeaderStart u FPdfCms.pas izvodi duljinu zaglavlja iz ContentLen, što DER čini egzaktnim, i to je ono što čuva AddSignatureTimestampToCms od kvarenja svakog CMS-a čiji je skup certifikata duži od 127 bajtova
Postavka je PAdES B-T nadogradnja. Atribut signature-time-stamp, onaj koji ETSI EN 319 122-1 klauzula 5.3 definira pod OID-om 1.2.840.113549.1.9.16.2.14, mora sletjeti u unsignedAttrs od SignerInfo opisanog u RFC 5652 klauzuli 5.3, a po definiciji se može dodati tek nakon što vrijednost potpisa postoji, jer se timestamp token računa nad tom vrijednošću. Dakle CMS je već izgrađen i već potpisan kada token stigne. Dodavanje jednog atributa mijenja duljinu SignerInfo, što mijenja duljinu SET-a signerInfos, pa SignedData, pa [0] EXPLICIT omotača, pa vanjskog ContentInfo. Svako zaglavlje koje obuhvaća mora se ponovno emitirati, a sve što nije na toj putanji mora se prenijeti bajt po bajt. Pregled B-LT i B-LTA pokriva što vam token donosi; ovaj je članak o četiri bajta ispred skupa certifikata koje je ponovna izgradnja stalno pogrešno slagala
Zašto dodavanje timestampa treba offset taga jednog srodnika?
Zato što ponovna izgradnja ponovno koristi četiri srodnika SET-a signerInfos doslovno, a reader prijavljuje gdje im je sadržaj, a ne gdje im je tag. TDerReader.ReadTlv vraća bajt taga, offset sadržaja, duljinu sadržaja i offset sljedećeg TLV-a. To je prava površina za spuštanje u strukturu, ali za kopiranje cijelog elementa trebate oktet na kojem mu sjedi tag, a jedino što pozivatelj drži je ContentOffs. CmsSliceTlv postoji da premosti tu prazninu: zadan offset i duljinu sadržaja vraća tag, length octete i sadržaj kao jedan buffer, a AddSignatureTimestampToCms poziva ga za OID contentType, INTEGER version, SET digestAlgorithms, SEQUENCE encapContentInfo i, kada je prisutan, skup certificates [0]
// Unutar AddSignatureTimestampToCms: spusti se, izreži srodnike doslovno
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;
// neobavezni 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 octeti + sadržaj
R.Position:= CN;
end;
Od tih pet izrezanih dijelova, četiri su sićušna: OID od jedanaest bajtova, INTEGER od tri bajta, skup algoritama sažetka od sedamnaest bajtova, detached encapContentInfo od trinaest bajtova. Skup certifikata je onaj koji nosi certifikat potpisnika i njegov lanac, a pravi X.509 certifikat ima najmanje nekoliko stotina bajtova. Skup certifikata je stoga jedini izrezani dio čiji su length octeti ikad u dugom obliku, i to je dio koji stari pomoćnik nije mogao locirati
Zašto se DER length octeti ne mogu čitati unatrag?
Zato što je broj length octeta pohranjen u prvom od njih, a čitanjem od sadržaja unatrag prvo nailazite na zadnji. X.690 klauzula 8.1.3.4 definira kratki oblik: jedan oktet, bit 8 čist, bitovi 7 do 1 drže duljinu od 0 do 127. Klauzula 8.1.3.5 definira dugi oblik: početni oktet s postavljenim bitom 8 čiji bitovi 7 do 1 daju broj sljedećih octeta, nakon čega ti octeti nose duljinu kao neoznačeni big-endian integer. Ništa u tom pravilu ne označava sljedeći oktet kao sljedeći. Njegov bit 8 je bit magnitude kao i svaki drugi, pa hodanje unatrag koje testira gornji bit Buf[ContentOffs- 1] testira podatkovni bit i zatim čita njegovih niskih sedam bitova kao broj
// Stari pomoćnik, kojemu je dan samo offset sadržaja
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
P, LenByte, LongLen: Integer;
begin
P:= ContentOffs- 1; // slijeće na ZADNJI length octet
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 certifikata 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)
Uzmimo zaglavlje skupa certifikata koji drži 1500 bajtova certifikata, A0 82 05 DC. Hodanje slijeće na DC, vidi postavljen gornji bit, izvlači 92 iz niskih sedam bitova i prijavljuje tag 94 bajta prije sadržaja, kada je on 4 bajta prije njega. U SignedData koji gradi BuildSignedData, sadržaj skupa certifikata sjedi samo nekoliko desetaka bajtova unutar CMS-a, pa izračunati offset nije bio samo preran nego negativan, a stari je kod štitio ContentOffs- 1 od pada ispod nule, ne svoj konačni rezultat. CmsSliceTlv zatim je uzeo izrezak devedesetak bajtova duži od elementa, počevši prije buffera, i ponovno izgrađeni SignedData nosio je taj izrezak tamo gdje je trebao biti njegov skup certifikata. Duljina od tri okteta čiji je zadnji oktet slučajno pao ispod $80, recimo A0 82 05 10, pala je na drugu stranu: hodanje ju je uzelo za oktet kratkog oblika i započelo izrezak na 05, dva bajta prekasno i unutar length octeta, bez ikakvog taga. Ishod je bio pogrešan u oba slučaja, samo je smjer varirao
Što DER garantira što izvođenje naprijed čini egzaktnim?
DER garantira da je kodiranje duljine čista funkcija duljine. X.690 klauzula 10.1 ograničava DER na definitivni oblik i zahtijeva najmanji broj octeta, što uklanja dvije slobode koje BER dopušta: neodređeni oblik i dopunjavanje duljine dugog oblika vodećim nultim octetima. Pod tim pravilom duljina sadržaja ispod 128 ima točno jedan length octet, a svaka druga duljina ima jedan početni oktet plus točno onoliko sljedećih octeta koliko duljina treba značajnih bajtova. Pozivatelj CmsHeaderStart već drži ContentLen, jer ga je ReadTlv upravo vratio, pa je duljina zaglavlja izračunljiva bez gledanja u ijedan bajt buffera
// Isporučeni pomoćnik: izvedi zaglavlje iz duljine sadržaja.
// Po X.690 10.1 length octeti su funkcija od ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
LengthOctets, Remaining: Integer;
begin
if ContentLen< 128 then
LengthOctets:= 1 // kratki oblik, 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); // jedan po značajnom bajtu
Remaining:= Remaining shr 8;
end;
end;
Result:= ContentOffs- LengthOctets- 1;
if Result< 0 then
Result:= ContentOffs;
end;
Dva detalja čine ovo sigurnim, a ne samo uvjerljivim. Prvo, pretpostavka da je ulaz DER provodi se uzvodno: TDerReader.TryReadTlvAt, na kojem je ReadTlv izgrađen, odbija neodređeni oblik, odbija duljinu dugog oblika čiji je prvi sljedeći oktet nula, i odbija jedan sljedeći oktet ispod $80. TLV koji stigne do CmsSliceTlv već je prošao te provjere, pa BER-ovski ne-minimalna duljina ne može stići do izvođenja i natjerati ga da laže. Drugo, fallback za negativan rezultat sada štiti pravi rezultat, a ne međurezultat. Vrijedi reći da je reader cijelo vrijeme znao offset taga: TDerTlv nosi i Offset i HeaderLength, a samo površina ReadTlv s četiri izlazna parametra ih odbacuje. Njihovo vraćanje bilo bi čišće dugoročno sučelje; isporučeni popravak čuva tu površinu netaknutom i čini pomoćnik ispravnim pod vlastitim uvjetima
Zašto su timestamp testovi prolazili s bugom na mjestu?
Zato što je svaki certifikat u uzorku bio dovoljno kratak za kratki oblik, a hodanje unatrag ispravno je upravo za taj slučaj. Tests.PadesTimestamp.pas gradi svoj certifikat potpisnika s SetLength(SignerCertDer, 32) u jednom testu i 64 u drugom, ispunjen rampom bajtova. Skup certifikata od 32 bajta kodira se kao A0 20, a onaj od 64 bajta kao A0 40, svaki s jednim length octetom. Hodanje unatrag od sadržaja slijeće na taj jedan oktet, njegov gornji bit je čist jer je to prvi i jedini length octet, i pomoćnik odgovara ispravno iz pogrešnog razloga. Suite od 1414 slučajeva bio je zelen, CMS s timestampom parsirao se, validator prve razine prijavio je B-T, i svaka od tih provjera izvršena je nad skupom certifikata kakav nijedan pravi dokument nikad nije sadržavao
Opće pravilo je onaj korisni dio. Kad god neka kodna putanja ovisi o tome kako je duljina kodirana, uzorak mora prijeći granicu kodiranja, a za DER to znači sadržaj duži od 127 bajtova, što prisiljava dugi oblik, i idealno duži i od 255 bajtova, što prisiljava drugi sljedeći oktet. Ista disciplina primjenjuje se na drugi slučaj u tom pregledu gdje samoverifikacija nije mogla vidjeti DER odstupanje: nesortirani SET OF u signedAttrs bio je nevidljiv za round trip iz istog izvora iz strukturno identičnog razloga, test je vježbao samo ulaze na kojima se pogrešni i ispravni kod slažu. Skica ispod poziva pomoćnik za izrezivanje izravno, što znači da ga treba izvesti iz FPdfCms.pas za testni build; ista granica dostupna je kroz javnu površinu tako da BuildSignedData predate certifikat lanca svake veličine i ponovno parsirate rezultat s timestampom
// Prikvači granicu: izrezak kroz zaglavlje dugog oblika 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 nakon zaglavlja; izrezak mora biti cijeli 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;
Gdje ponovna izgradnja još povlači svoje granice
AddSignatureTimestampToCms napisan je za CMS koji emitira BuildSignedData, i njegove granice iz toga slijede. Hodanje očekuje jedan SignerInfo i ponovno emitira samo taj jedan, pa bi strani CMS s više potpisnika bio vraćen s jednim potpisnikom; prepoznaje neobavezni skup certificates [0] ali ne skup crls [1], a CMS koji ga nosi pada glasno s iznimkom signerInfos SET expected, umjesto da tiho pogrešno izreže. Novi unsignedAttrs drži jedan atribut, pa je pravilo poretka SET OF iz X.690 klauzule 11.6 trivijalno zadovoljeno i ne treba sortiranje. A potpisani dio netaknut je po konstrukciji: prefiks SignerInfo kroz OCTET STRING potpisa kopira se doslovno, zato validator koji ponovno računa sažetak signedAttrs vidi iste bajtove prije i nakon što je timestamp dodan. Kada neki ipak odbije dokument, uzroci su obično drugdje i zaslužuju vlastitu kontrolnu listu
DER reader, pisac, CMS builder i ovo ubacivanje timestampa isporučuju se kao Pascal izvorni kod uz PDFium Delphi komponentu, i bug ovakvog oblika argument je za to: kada ponovno izgrađeni SignedData izađe devedeset bajtova predugačak, želite pročitati pomoćnik koji je rezao izrezak i klauzulu X.690 koju je pogrešno pročitao, a ne stack trace iz crne kutije