A PDFium Component a beágyazott CMS-struktúra kezdetét a tartalomhosszából keresi ki, soha nem a hosszúság-oktetteken visszafelé haladva, mert a tartalom előtti bájt a legutolsó hosszúság-oktett, és semmit nem mond arról, hány előzi meg. A FPdfCms.pas-ban lévő CmsHeaderStart ehelyett a ContentLen értékéből vezeti le a fejléchosszt, amit a DER pontossá tesz, és ez az, ami megőrzi a AddSignatureTimestampToCms műveletet attól, hogy tönkretegyen minden olyan CMS-t, amelynek a tanúsítványhalmaza hosszabb 127 bájtnál
A szóban forgó beállítás a PAdES B-T szintre lépés. Az aláírás-időbélyeg attribútumnak, annak, amelyet az ETSI EN 319 122-1 5.3. pontja definiál a 1.2.840.113549.1.9.16.2.14 OID alatt, az RFC 5652 5.3. pontjában leírt SignerInfo unsignedAttrs részébe kell bekerülnie, és definíció szerint csak azután adható hozzá, hogy az aláírásérték létezik, mert az időbélyeg-token azon az értéken számolódik. A CMS tehát már fel van építve és alá is van írva, mire a token megérkezik. Egyetlen attribútum hozzáadása megváltoztatja a SignerInfo hosszát, ami megváltoztatja a signerInfos SET, majd a SignedData, aztán a [0] EXPLICIT burkoló, végül a külső ContentInfo hosszát. Minden befoglaló fejlécet újra ki kell bocsátani, és mindent, ami nincs ezen az úton, bájtról bájtra át kell vinni. A B-LT és B-LTA végigvezetés azt tárgyalja, mit nyer a tokennel; ez a cikk arról a négy bájtról szól, amely a tanúsítványhalmaz előtt áll, és amelyet az újraépítés folyton elrontott
Miért kell időbélyeg hozzáadásakor egy testvérelem tag-offszetje?
Mert az újraépítés a signerInfos SET négy testvérelmét használja fel szó szerint, az olvasó pedig azt jelenti, hol van a tartalmuk, nem azt, hol van a tagjük. A TDerReader.ReadTlv visszaadja a tag bájtját, a tartalom offszetjét, a tartalom hosszát és a következő TLV offszetjét. Ez a megfelelő felület egy struktúrába való leereszkedéshez, egy teljes elem átmásolásához viszont arra az oktettre van szükség, ahol a tagja ül, a hívónak pedig csak a ContentOffs van a kezében. A CmsSliceTlv azért létezik, hogy áthidalja ezt a rést: egy tartalom-offszet és -hossz megadásával a taget, a hosszúság-oktetteket és a tartalmat adja vissza egyetlen pufferben, az AddSignatureTimestampToCms pedig ezt hívja a contentType OID-hoz, a version INTEGER-hez, a digestAlgorithms SET-hez, az encapContentInfo SEQUENCE-hez és — ha jelen van — a certificates [0] halmazhoz
// Az AddSignatureTimestampToCms belsejében: leereszkedés, a testvérek szó szerinti kivágása
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;
// opcionális 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 + hosszúság-oktettek + tartalom
R.Position:= CN;
end;
Ebből az öt kivágásból négy nagyon kicsi: egy tizenegy bájtos OID, egy három bájtos INTEGER, egy tizenhét bájtos digest-algoritmus halmaz, egy tizenhárom bájtos, leválasztott encapContentInfo. A tanúsítványhalmaz az, amely az aláíró tanúsítványát és annak láncát hordozza, egy valódi X.509 tanúsítvány pedig legalább több száz bájt hosszú. A tanúsítványhalmaz így az egyetlen kivágás, amelynek a hosszúság-oktettjei valaha is hosszú formában vannak, és pontosan ez az a kivágás, amelyet a régi segédfüggvény nem tudott megtalálni
Miért nem járhatók be visszafelé a DER hosszúság-oktettjei?
Mert a hosszúság-oktettek számát ezek közül az első tárolja, és a tartalomtól visszafelé olvasva az utolsóval találkozunk először. Az X.690 8.1.3.4. pontja definiálja a rövid formát: egyetlen oktett, a 8. bit törölve, a 7–1. bitek pedig 0 és 127 közötti hosszt hordoznak. A 8.1.3.5. pont a hosszú formát definiálja: egy kezdő oktett beállított 8. bittel, amelynek a 7–1. bitjei a rákövetkező oktettek számát adják meg, majd azok az oktettek következnek, amelyek a hosszt előjel nélküli big-endian egészként hordozzák. A szabály semmivel nem jelöli meg, hogy egy rákövetkező oktett rákövetkező. A 8. bitje ugyanolyan nagyságrendi bit, mint bármelyik más, így az a visszafelé haladó bejárás, amely a Buf[ContentOffs- 1] felső bitjét vizsgálja, egy adatbitet vizsgál, majd az alsó hét bitjét olvassa ki darabszámként
// A régi segédfüggvény, csak a tartalom-offszet ismeretében
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
P, LenByte, LongLen: Integer;
begin
P:= ContentOffs- 1; // az UTOLSÓ hosszúság-oktettre érkezik
if P< 0 then
Exit(ContentOffs);
LenByte:= Buf[P];
if (LenByte and $80)= 0 then // csak az ELSŐ esetében értelmes
Result:= P- 1
else
begin
LongLen:= LenByte and $7F;
Result:= P- LongLen- 1;
end;
end;
// Egy 1500 bájtos tanúsítványhalmaz fejléce: A0 82 05 DC
// Buf[ContentOffs- 1]= $DC -> bit 8 set, $DC and $7F= 92
// Result= ContentOffs- 94 (a tag a ContentOffs- 4 helyen van)
Vegyük egy 1500 bájt tanúsítványt tartalmazó tanúsítványhalmaz fejlécét, A0 82 05 DC. A bejárás a DC-re érkezik, beállított felső bitet lát, az alsó hét bitből 92-t fejt ki, és a taget a tartalom előtt 94 bájttal jelenti, holott az 4 bájttal van előtte. Egy BuildSignedData-val épített SignedData esetében a tanúsítványhalmaz tartalma csak néhány tucat bájtra van a CMS elejétől, így a kiszámított offszet nem csupán korai, hanem negatív is volt, a régi kód pedig a ContentOffs- 1 értéket védte a nulla alá csúszástól, nem pedig a végeredményét. A CmsSliceTlv ezután kilencvenvalahány bájttal hosszabb szeletet vett ki, mint az elem, méghozzá a puffer előtt kezdve, az újraépített SignedData pedig azt a szeletet hordozta ott, ahol a tanúsítványhalmazának kellett volna lennie. Egy háromoktettes hossz, amelynek az utolsó oktettje épp a $80 alá esett, mondjuk az A0 82 05 10, a másik irányban vallott kudarcot: a bejárás rövid formájú oktettnek vette, és a szeletet a 05-nél kezdte, két bájttal később és a hosszúság-oktettek belsejében, egyáltalán tag nélkül. Az eredmény mindkét esetben hibás volt, csak az irány tért el
Mit garantál a DER, amitől pontos lesz az előre vezetett levezetés?
A DER garantálja, hogy a hossz kódolása a hossz tiszta függvénye. Az X.690 10.1. pontja a DER-t a határozott formára szorítja, és megköveteli a minimális oktettszámot, ami megszünteti a BER által megengedett két szabadságfokot: a határozatlan formát, valamint a hosszú formájú hossz vezető nulla oktettekkel való feltöltését. E szabály szerint a 128 alatti tartalomhossznak pontosan egy hosszúság-oktettje van, minden más hossznak pedig egy kezdő oktettje plusz pontosan annyi rákövetkező oktettje, ahány jelentős bájt a hosszhoz kell. A CmsHeaderStart hívója már a kezében tartja a ContentLen értékét, mert a ReadTlv épp visszaadta, így a fejléchossz a puffer egyetlen bájtjának megtekintése nélkül kiszámítható
// A kiszállított segédfüggvény: a fejléc levezetése a tartalomhosszból.
// Az X.690 10.1 szerint a hosszúság-oktettek a ContentLen függvényei
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
LengthOctets, Remaining: Integer;
begin
if ContentLen< 128 then
LengthOctets:= 1 // rövid forma, X.690 8.1.3.4
else
begin
LengthOctets:= 1; // a kezdő oktett, X.690 8.1.3.5
Remaining:= ContentLen;
while Remaining> 0 do
begin
Inc(LengthOctets); // egy-egy jelentős bájtonként
Remaining:= Remaining shr 8;
end;
end;
Result:= ContentOffs- LengthOctets- 1;
if Result< 0 then
Result:= ContentOffs;
end;
Két részlet teszi ezt biztonságossá, nem csupán hihetővé. Először: azt a feltevést, hogy a bemenet DER, felfelébb kényszerítjük ki — a TDerReader.TryReadTlvAt, amelyre a ReadTlv épül, visszautasítja a határozatlan formát, visszautasítja azt a hosszú formájú hosszt, amelynek az első rákövetkező oktettje nulla, és visszautasítja az egyetlen, $80 alatti rákövetkező oktettet. Az a TLV, amely eljut a CmsSliceTlv-ig, már átment ezeken az ellenőrzéseken, így BER-stílusú, nem minimális hossz nem juthat el a levezetésig, hogy hazuggá tegye. Másodszor: a negatív eredményre vonatkozó tartalékút mostantól a valódi választ őrzi, nem egy közbülsőt. Érdemes kimondani, hogy az olvasó végig tudta a tag offszetjét: a TDerTlv egyszerre hordozza az Offset és a HeaderLength mezőt, és csak a négy kimeneti paraméterű ReadTlv felület dobja el őket. Ezek visszaadása lenne a tisztább hosszú távú interfész; a kiszállított javítás érintetlenül hagyja azt a felületet, és a segédfüggvényt a saját feltételei szerint teszi helyessé
Miért mentek át az időbélyeg-tesztek a hiba jelenlétében is?
Mert minden tesztelem-tanúsítvány elég rövid volt a rövid forma használatához, a visszafelé haladó bejárás pedig pontosan arra az esetre helyes. A Tests.PadesTimestamp.pas az egyik tesztben SetLength(SignerCertDer, 32), a másikban 64 értékkel építi fel az aláíró tanúsítványát, bájtlépcsővel feltöltve. Egy 32 bájtos tanúsítványhalmaz A0 20-ként, egy 64 bájtos A0 40-ként kódolódik, mindkettő egyetlen hosszúság-oktettel. A tartalomtól visszafelé haladva azon az egy oktetten landolunk, a felső bitje törölve van, mert ő az első és egyetlen hosszúság-oktett, és a segédfüggvény rossz okból ad helyes választ. Az 1414 esetből álló sorozat zöld volt, az időbélyeggel ellátott CMS feldolgozódott, az 1. szintű validátor B-T-t jelentett, és mindezeket az ellenőrzéseket olyan tanúsítványhalmazon futtattuk, amilyet valódi dokumentum soha nem tartalmazott
Az általános szabály a hasznos rész. Amikor egy kódút attól függ, hogyan kódolódik egy hossz, a tesztelemnek át kell lépnie a kódolási határt, a DER esetében ez 127 bájtnál hosszabb tartalmat jelent, ami kikényszeríti a hosszú formát, ideális esetben pedig 255 bájtnál is hosszabbat, ami egy második rákövetkező oktettet kényszerít ki. Ugyanez a fegyelem érvényes abban a másik esetre is abból a vizsgálatból, ahol az önellenőrzés nem látta meg a DER-eltérést: a signedAttrs rendezetlen SET OF halmaza szerkezetileg azonos okból volt láthatatlan egy azonos forrású körbejáratás számára, a teszt csak olyan bemeneteken futott, amelyeken a hibás és a helyes kód egyetért. Az alábbi vázlat közvetlenül a szeletsegédfüggvényt hívja, ami azt jelenti, hogy a tesztbuild számára exportálni kell a FPdfCms.pas-ból; ugyanez a határ elérhető a nyilvános felületen keresztül is, ha a BuildSignedData-nak minden méretből adunk egy lánctanúsítványt, és újra feldolgozzuk az időbélyeggel ellátott eredményt
// A határ rögzítése: a hosszú formájú fejlécen átvezető szeletnek a tagnél kell kezdődnie
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;
// a tartalom közvetlenül a fejléc után kezdődik; a szeletnek a teljes TLV-nek kell lennie
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;
Hol húzza meg a határait az újraépítés
Az AddSignatureTimestampToCms arra a CMS-re íródott, amelyet a BuildSignedData bocsát ki, és a korlátai ebből következnek. A bejárás egyetlen SignerInfo elemet vár, és csak azt bocsátja ki újra, így egy idegen, több aláírós CMS egyetlen aláíróval térne vissza; felismeri az opcionális certificates [0] halmazt, de a crls [1] halmazt nem, és az ilyet hordozó CMS hangosan elbukik a signerInfos SET expected kivétellel, nem pedig csendben vág rossz szeletet. Az új unsignedAttrs egyetlen attribútumot hordoz, így az X.690 11.6. pontjának SET OF rendezési szabálya triviálisan teljesül, és nem igényel rendezést. Az aláírt rész pedig felépítésénél fogva érintetlen: a SignerInfo elejétől az aláírás OCTET STRING-ig minden szó szerint másolódik, ezért lát ugyanazokat a bájtokat az a validátor, amely újraszámolja a signedAttrs digestjét, az időbélyeg hozzáadása előtt és után is. Ha valami mégis visszautasítja a dokumentumot, az okok rendszerint máshol vannak, és megérdemlik a saját ellenőrzőlistájukat
A DER-olvasó, az író, a CMS-építő és ez az időbélyeg-beszúrás mind Pascal forrásként érkezik a PDFium Delphi komponenssel, és egy ilyen alakú hiba épp e mellett érvel: amikor egy újraépített SignedData kilencven bájttal hosszabban jön ki, a szeletet kivágó segédfüggvényt és az X.690 félreolvasott pontját akarja elolvasni az ember, nem egy fekete doboz stack trace-ét