Technikai cikk

Miért utasítják el az ellenőrzők a PAdES aláírásokat: PDFium Delphiben

Az ellenőrzők szinte minden olyan esetben, amelyet a Delphiben lévő PDFium komponenssel hibakerestünk, a következő három ok egyike miatt utasítják el a PAdES aláírásokat: a /ByteRange tömb még mindig a nulla helyőrzőit tartalmazza, a /M aláírási idő nem megfelelően formázott PDF dátum-karakterlánc, vagy egy növekményes frissítés elhagyta a /Encrypt bejegyzést egy titkosított dokumentumból. Mindhárom esetben olyan fájlok keletkeznek, amelyek jól nyílnak meg, jól renderelődnek, de megbuknak abban a pillanatban, amikor egy szabványszerű ellenőrző beolvassa az aláírási szótárat.

A forgatókönyv, amely ezt a cikket motiválja, fájdalmasan specifikus. Aláír egy szerződést a PAdES B-B aláírásról szóló cikkben leírt munkafolyamattal, a saját ellenőrző kódja szerint az aláírás jelen van és szerkezetileg ép, minden helyi teszt zöld — majd a másik fél feltölti a fájlt a saját ellenőrző platformjára, és egy piros keresztet kap. A hibából semmi sem látható a megjelenítőben, mivel ezen három hiba egyike sem érinti az oldal tartalmát. Teljesen az aláírási szótárban és a fájl lezárójában (trailer) élnek, ami pontosan az a hely, ahol az ellenőrzők keresnek, a megjelenítők pedig többnyire nem.

Miért érvénytelen a PDF aláírásom ByteRange-e?

A ByteRange elutasítása szinte mindig azt jelenti, hogy a négy mező soha nem lett kitöltve, nem pedig azt, hogy a tartományok finoman rosszak lennének. A /ByteRange [A B C D] tömb két tartományt deklarál: az A-tól A+B-ig tartó bájtokat, valamint a C-től C+D-ig tartó bájtokat, az EN 319 142-1 §6.3 (k követelmény) pedig megköveteli, hogy ezek együttesen lefedjék a teljes fájlt, kivéve a hexadecimálisan kódolt /Contents karakterláncot. Számokban: az A értéke 0, C >= A+B, a C+D megegyezik a fájl hosszával, és a B és C közötti rés pontosan a <...> hexadecimális karakterláncot tartalmazza — két szögletes zárójel bájt, plusz a CMS adatok bájtonkénti két hexadecimális karaktere. Az az ellenőrző, amely [0 0 0 0]-t olvas be, arra a következtetésre jut, hogy az aláírás semmit sem fed le, és elutasítja azt, függetlenül attól, hogy a /Contents-en belüli CMS struktúra mennyire helyes.

Ennek a folyamatnak a működését érdemes megérteni, mert ugyanez a minta minden aláíró szoftverben (stack) létezik. Az aláíró nem tudhatja a végső eltolásokat (offsets), amíg a fájl elrendezése el nem készült, így az író fix szélességű nulla helyőrzőkkel bocsátja ki a tömböt, és az elrendezés után tölti ki azokat. A PDFium komponens 2.14.1 előtti kiadásaiban ez a kitöltés a helyőrző mintát a /Contents pozíciótól kezdve kereste — bu a /ByteRange a /Contents előtt helyezkedik el a szótárban, így a keresés semmit sem talált, és mindhárom helyettesítés csendben meghiúsult. A CMS kivonat a helyes tartományokon lett kiszámítva, így a kriptográfia ép volt; ezen tartományok deklarációja viszont nullán maradt, így minden szabványszerű ellenőrző elutasította a fájlt. Ugyanezen a módon buktak el a PAdES B-LTA dokumentum-időbélyegek (Document Time-stamps) is, amelyek ugyanazt a szótárelrendezést használják — ez fontos, ha a hosszú távú B-LT és B-LTA aláírásokra épít. A 2.14.1-es verzió az aláírási objektum kezdetétől tölti ki a helyőrzőket, és a javítást egy olyan regressziós teszt fedi le, amely elemzi az előállított fájlt, és megerősíti az alábbi aritmetikát.

function SignedByteRangeCoversFile(const FileName: string): Boolean;
var
  Raw: TBytes;
  Text: AnsiString;
  P, N: Integer;
  F: array[0..3] of Int64;
begin
  Raw := TFile.ReadAllBytes(FileName);
  SetString(Text, PAnsiChar(@Raw[0]), Length(Raw));
  P := Pos('/ByteRange', Text);          // csak az első aláírás
  Result := P > 0;
  if not Result then Exit;
  Inc(P, Length('/ByteRange'));
  for N := 0 to 3 do
  begin
    while (P <= Length(Text)) and not (Text[P] in ['0'..'9']) do Inc(P);
    F[N] := 0;
    while (P <= Length(Text)) and (Text[P] in ['0'..'9']) do
    begin
      F[N] := F[N] * 10 + Ord(Text[P]) - Ord('0');
      Inc(P);
    end;
  end;
  // EN 319 142-1 §6.3 k követelmény: a tartományok mindent lefednek, kivéve a /Contents-et
  Result := (F[0] = 0) and (F[2] >= F[0] + F[1]) and
            (F[2] + F[3] = Int64(Length(Raw)));
end;

Húsz sornyi egyszerű RTL kód, könyvtári hívások nélkül, és elkapja a teljes hibaosztályt a gyártási ponton. Ha egyetlen állítást (assertion) megtart ebből a cikkből, tartsa meg ezt: elemezze a saját aláírt kimenetét, és ellenőrizze a három egyenlőséget, mielőtt a fájl elhagyná a folyamatot.

Miért jelölik meg az ellenőrzők a /M aláírási időt hibás formátumúként?

A szigorú ellenőrzők elutasítják azt az aláírási időt, amely nem teljes PDF dátum-karakterlánc, és a leggyakrabban hiányzó két rész a D: előtag és az UTC eltolás (offset) jelző. Az ISO 32000-1 §7.9.4 a dátumformátumot D:YYYYMMDDHHmmSS alakban határozza meg, amelyet egy eltolás követ — Z az UTC-hez, vagy egy előjeles +HH'mm' kapcsolat ahhoz. Egy csupasz 20260709143000 dátumként értelmeződik a megengedő olvasók számára, de a nyelvtant szó szerint alkalmazó ellenőrző egy hibás formátumú karakterláncot lát egy kötelező formátumú mezőben, és megjelöli az aláírást. A PDFium komponens 2.14.4 előtti kiadásai pontosan ebben a csupasz formában írták be a TPdf.SignPades és SignPadesBytes /M bejegyzését; a 2.14.4-es verzió óta a bejegyzés hordozza a D: előtagot és a Z jelzőt, így a deklarált aláírási idő D:20260709143000Z formában olvasható.

Két gyakorlati megjegyzés kapcsolódik ehhez a mezőhöz. Elsőként, írja le az UTC-t és jelezze azt: az eltolásjelző nélküli időbélyeg arra kényszeríti az ellenőrzőt, hogy találgassa az időzóna-kapcsolatot, és a §7.9.4 az eltolást a formátum részének tekinti, nem pedig opcionális finomságnak. Másodszor, emlékezzen arra, mi a /M — az aláíró által bejelentett idő, állítás és nem bizonyíték. Az ellenőrző itt a formátumot vizsgálja, nem az igazságtartalmát; a bizonyítható idő egy RFC 3161 időbélyegzőből származik a PAdES B-T szinten és felette. A mező helyes formázása és a benne való bizalom különálló döntések, és az ellenőrzők csak az elsőt kényszerítik ki.

Miért vált ki a SignPades EPadesCrypto kivételt egy titkosított PDF-en?

A PDFium komponens megtagadja a titkosított dokumentumok aláírását, mert az alternatíva egy olyan fájl előállítása lenne, amelyet a szabványszerű olvasók megnyitáskor tönkretennének. A PAdES aláírást növekményes frissítésként (incremental update) fűzik hozzá, és az ISO 32000-1 §7.5.6 megköveteli, hogy a frissítési szakasz új lezárója (trailer) hordozza az előző lezáró minden bejegyzését, kivéve a /Prev-et — egy titkosított dokumentumban ez magában foglalja a /Encrypt-et is. Ha ezt elhagyja, a legújabb lezáró a fájlt titkosítatlannak nyilvánítja, így a szabványszerű olvasó a titkosított törzset egyszerű szövegként (plaintext) értelmezi, és szemét adatokat kap. Ami még rosszabb, az aláíró által hozzáfűzött folyamokat és karakterláncokat maguknak is le kellene titkosítani a dokumentum kulcsával ahhoz, hogy legálisak legyenek, amit egy egyszerű szöveges aláírás-befecskendező nem tud megtenni. Nincs mód arra, hogy érvényes egyszerű szöveges aláírást fűzzünk egy titkosított fájlhoz, így a 2.14.2-es verzió óta a TPdf.SignPades, SignPadesBytes és InjectPadesDssMarkers metódusok a EPadesCrypto kivételt váltják ki a sérült vagy ellenőrizhetetlen eredmény kibocsátása helyett.

try
  if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
    Writeln('Signing reported failure');
except
  on E: EPadesCrypto do
  begin
    // pl. 'SignPadesBytes: a forrásdokumentum titkosított;
    //       távolítsa el a titkosítást az aláírás előtt'
    Writeln('Cannot sign: ', E.Message);
  end;
end;

A kivétel a helyes eredmény, ezért a munkafolyamatot e köré tervezze ahelyett, hogy elkapná és újra próbálkozna. Először dekódolja a tulajdonosi jogosultsággal (owner authority), írja alá az egyszerű szöveges másolatot, és ha a terjesztési csatorna titkosítást igényel, fogadja el, hogy a titkosítás-majd-aláírás és az aláírás-majd-titkosítás különböző műtárgyakat (artifacts) eredményez különböző ellenőrzési történetekkel. Az egyszerű szöveges bájtok felett kiszámított aláírás nem élheti túl ezen bájtok újratitkosítását, így a tiszta lehetőségek a következők: egy aláírt egyszerű szöveges fájl vagy a kód mellett dokumentált irányelvi döntés.

Hogyan igazolhatja két hiba egymást

A ByteRange hiba addig maradt rejtve, amíg rejtve maradt, mert a saját ellenőrzőnk (validator) egy kiegészítő módon hibás volt, és ez a leginkább átvihető tanulság ebben a cikkben. A ValidatePadesCompliance-ben lévő lefedettség-ellenőrzés megkövetelte, hogy a második tartomány pontosan az A+B-nél kezdődjön — nulla hézag —, ami tévesen osztályozza azt a szabványos elrendezést, ahol a rés a /Contents hexadecimális karakterláncot tartalmazza. Így a házon belüli ellenőrző pontosan a szabványszerű elrendezést utasította el, és a házon belüli generátor soha nem állított elő olyat, a teljes végpontok közötti aláírás-majd-ellenőrzés folyamatok pedig zöldek maradtak. Mindegyik hiba megfosztotta a tesztcsomagot attól az ellenpéldától, amely leleplezte volna a másikat. A 2.14.1-es verzió mindkét oldalt javította ugyanabban a kiadásban: a generátor mind a négy mezőt kitölti, az ellenőrző pedig elfogadja a C >= A+B-t, ahol a C+D megegyezik a fájl hosszával.

A módszertani javítás a keresztellenőrzés egy olyan implementációval szemben, amelyet nem Ön írt. Egy generátor és egy ellenőrző, amely osztozik egy kódalapon, egy szerzőn, vagy akár csak a formátum mentális modelljén, a végtelenségig egyetérthet egy közös félreértésben; egy független ellenőrző megtöri ezt a szimmetriát. A kiadás előtt futtassa át az aláírt kimenetet legalább egy külső megfelelőség-ellenőrzőn, és tartson fenn egy struktúra-önellenőrzést a buildben, mint gyors első vonal — a TPdf.ValidatePades, amelyet az aláírás-ellenőrző cikk ír le, nevesített problémaként jelenti a lefedettségi hibát.

var
  R: TPadesValidationResult;
begin
  Pdf.FileName := 'contract-signed.pdf';
  Pdf.Active := True;
  R := Pdf.ValidatePades;
  if ppeiByteRangeNotCoveringFile in R.Issues then
    Writeln('ByteRange does not cover the file');
  if not R.IsCompliant then
    Writeln('Structural issues present: do not ship this file');
end;

Elutasítási ellenőrzőlista az aláírt kimenethez

Ha egy platform elutasítja az Ön PAdES aláírását, ellenőrizze az olcsó strukturális okokat, mielőtt tanúsítványokra vagy bizalmi láncokra gyanakodna. Olvassa le a /ByteRange tömböt, és ellenőrizze a három egyenlőséget: az első mező nulla, a második szakasz az első végénél vagy az után kezdődik, a második szakasz pedig pontosan a fájl végén ér véget. Olvassa le az /M bejegyzést, és ellenőrizze, hogy az egy teljes §7.9.4 szerinti dátum-karakterlánc-e a D: előtaggal és egy eltolásjelzővel. Győződjön meg arról, hogy a forrásdokumentum nem volt titkosítva az aláírás hozzáfűzésekor — és ha a rendszere ennek ellenére panasz nélkül aláírta, tekintse ezt a hallgatást a szoftver hibájának. Mindhárom ellenőrzés nyers bájtokon fut le ezredmásodpercek alatt, és tapasztalataink szerint sokkal gyakrabban magyarázzák az elutasítást, mint bármely kriptográfiai ok.

Az itt használt aláírási, ellenőrzési és érvényesítési hívások — a SignPades, ValidatePades és a PAdES megfelelőségi ellenőrzések a javított ByteRange aritmetikával, dátumformázással és titkosítási kapuval — a Delphihez, C++Builderhez és Lazarushoz készült PDFium komponenssel együtt érkeznek.