Technický článek

Proč validátory odmítají PAdES podpisy: PDFium v Delphi

Validátor odmítá podpis PAdES z jednoho ze tří důvodů téměř v každém případě, který jsme s komponentou PDFium Component v Delphi ladili: pole /ByteRange stále obsahuje své nulové zástupné znaky, čas podpisu /M není správně naformátovaný textový řetězec data PDF, nebo přírůstková (inkrementální) aktualizace odstranila položku /Encrypt ze šifrovaného dokumentu. Všechny tři případy vedou k souborům, které lze bez problémů otevřít i vykreslit, ale selžou v okamžiku, kdy kompatibilní validátor načte slovník podpisu

Scénář, který nás vedl k napsání tohoto článku, je až bolestně specifický. Podepíšete smlouvu pomocí postupu popsaného v článku o podepisování PAdES B-B, váš vlastní kód pro kontrolu hlásí, že podpis je přítomen a strukturálně v pořádku, všechny lokální testy svítí zeleně — a poté druhá strana nahraje soubor na svou validační platformu a zobrazí se červený křížek. V prohlížeči přitom není vidět žádná chyba, protože žádný z těchto tří nedostatků neovlivňuje obsah stránky. Nacházejí se totiž výhradně ve slovníku podpisu a v patičce souboru (traileru), což je přesně místo, kam se validátory dívají a prohlížeče ho většinou ignorují

Proč je ByteRange mého PDF podpisu neplatný?

Odmítnutí kvůli neplatnému ByteRange téměř vždy znamená, že tato čtyři pole nebyla nikdy vyplněna, nikoli že by rozsahy byly jemně chybné. Pole /ByteRange [A B C D] deklaruje dva rozsahy, bajty AA+B a bajty CC+D, a norma EN 319 142-1 §6.3 (požadavek k) vyžaduje, aby společně pokrývaly celý soubor s výjimkou hexadecimálně kódovaného řetězce /Contents. Vyjádřeno čísly: A je 0, C >= A+B, C+D odpovídá délce souboru a mezera mezi B a C obsahuje přesně hexadecimální řetězec <...> — dva znaky závorek plus dva hexadecimální znaky na každý bajt dat CMS. Validátor, který načte [0 0 0 0], dojde k závěru, že podpis nepokrývá nic, a odmítne jej bez ohledu na to, jak správná je struktura CMS uvnitř /Contents

Mechanismus toho, jak k tomu dochází, stojí za pochopení, protože stejný vzorec chování existuje v každém podepisovacím nástroji. Podepisující nemůže znát konečné offsety, dokud není soubor kompletně sestaven, takže zapisovač vygeneruje pole s nulovými zástupnými znaky o pevné šířce a po sestavení je dodatečně vyplní. Ve verzích komponenty PDFium Component před 2.14.1 toto dodatečné vyplňování hledalo zástupný vzor počínaje pozicí /Contents — ale /ByteRange se ve slovníku nachází před /Contents, takže hledání nic nenašlo a všechna tři nahrazení tiše selhala. CMS hash byl spočítán nad správnými rozsahy, takže kryptografie byla v pořádku, avšak deklarace těchto rozsahů zůstala na nule. Všechny kompatibilní validátory proto soubor odmítly. Časová razítka dokumentů PAdES B-LTA, která používají stejné rozvržení slovníku, selhávala stejným způsobem — což je důležité, pokud stavíte na dlouhodobých podpisech B-LT and B-LTA. Verze 2.14.1 provádí dodatečné vyplňování od začátku objektu podpisu a tato oprava je pokryta regresním testem, který analyzuje vytvořený soubor a ověřuje níže uvedenou matematiku

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);          // first signature only
  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 req k: spans cover everything except /Contents
  Result := (F[0] = 0) and (F[2] >= F[0] + F[1]) and
            (F[2] + F[3] = Int64(Length(Raw)));
end;

Dvacet řádků prostého kódu RTL bez volání externích knihoven dokáže zachytit celou tuto třídu chyb hned při generování souboru. Pokud si z tohoto článku máte odnést jedno ověření (assertion), pak právě toto: analyzujte svůj vlastní podepsaný výstup a zkontrolujte tyto tři rovnosti předtím, než soubor opustí vaši aplikaci

Proč validátory označují čas podpisu /M za neplatný?

Přísné validátory odmítají čas podpisu, který není úplným textovým řetězcem data PDF, přičemž nejčastěji chybí předpona D: a značka časového posunu UTC. Norma ISO 32000-1 §7.9.4 definuje formát data jako D:YYYYMMDDHHmmSS následovaný posunem — Z pro UTC nebo podepsaným vztahem +HH'mm' k němu. Samotná hodnota 20260709143000 sice může být pro tolerantní čtečku čitelná, ale validátor aplikující gramatiku doslovně uvidí neplatný řetězec v povinně formátovaném poli a podpis označí za chybný. Verze PDFium Component před 2.14.4 zapisovaly položku /M v metodách TPdf.SignPades a SignPadesBytes přesně v této prosté podobě; od verze 2.14.4 již položka obsahuje předponu D: a značku Z, takže deklarovaný čas podpisu má formát D:20260709143000Z

K tomuto poli se vážou dvě praktické poznámky. Zaprvé, zapisujte čas v UTC a explicitně to uveďte: časové razítko bez značky posunu nutí validátor odhadovat vztah k časovému pásmu a sekce §7.9.4 považuje posun za součást formátu, nikoli za volitelný doplněk. Zadruhé, pamatujte na to, co /M představuje — je to čas deklarovaný podepisujícím, tedy tvrzení, nikoli důkaz. Validátor zde kontroluje pouze formát, nikoli pravdivost údajů; prokazatelný čas pochází až z časového razítka RFC 3161 na úrovni PAdES B-T a vyšší. Správné naformátování pole a důvěra v jeho hodnotu jsou dvě odlišná rozhodnutí, přičemž validátory vynucují pouze to první

Proč SignPades vyvolává výjimku EPadesCrypto u šifrovaného PDF?

Komponenta PDFium Component odmítá podepsat šifrovaný dokument, protože alternativou by bylo vytvoření souboru, který kompatibilní čtečky při otevření znehodnotí. Podpis PAdES se připojuje jako přírůstková aktualizace a norma ISO 32000-1 §7.5.6 vyžaduje, aby nová patička (trailer) aktualizované části nesla všechny položky předchozí patičky kromě /Prev — u šifrovaného dokumentu to zahrnuje i /Encrypt. Pokud jej vynecháte, nejnovější patička deklaruje soubor jako nešifrovaný, takže kompatibilní čtečka bude analyzovat šifrované tělo jako prostý text a načte nečitelná data. Navíc toky dat a řetězce, které podepisující připojuje, by musely být samy šifrovány klíčem dokumentu, což nástroj pro vkládání podpisů v prostém textu nedokáže. Neexistuje žádný způsob, jak k šifrovanému souboru připojit platný nešifrovaný podpis, proto od verze 2.14.2 metody TPdf.SignPades, SignPadesBytes a InjectPadesDssMarkers vyvolávají výjimku EPadesCrypto namísto vytvoření poškozeného nebo neověřitelného výsledku

try
  if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
    Writeln('Signing reported failure');
except
  on E: EPadesCrypto do
  begin
    // e.g. 'SignPadesBytes: the source document is encrypted;
    //       remove encryption before signing'
    Writeln('Cannot sign: ', E.Message);
  end;
end;

Jak se mohou dvě chyby vzájemně krýt

Chyba ByteRange se skrývala tak dlouho proto, že náš vlastní validátor obsahoval doplňující chybu — a to je nejdůležitější ponaučení z tohoto článku. Kontrola pokrytí v metodě ValidatePadesCompliance vyžadovala, aby druhý rozsah začínal přesně na pozici A+B — tedy s nulovou mezerou —, což chybně vyhodnotilo standardní rozvržení, kde mezera obsahuje hexadecimální řetězec /Contents. Interní validátor tak odmítal přesně to rozvržení, které bylo v souladu s normou, a interní generátor ho nikdy nevytvořil, což umožnilo, že ucelené testovací řetězce (sign-then-validate) zůstávaly zelené. Každá z těchto chyb bránila testovací sadě v odhalení té druhé. Verze 2.14.1 opravila obě strany v rámci jednoho vydání: generátor již řádně doplňuje všechna čtyři pole a validátor akceptuje vztah C >= A+B, přičemž C+D odpovídá délce souboru

Metodickou opravou je křížové ověření (cross-validation) proti implementaci, kterou jste sami nenapsali. Generátor a validátor, které sdílejí stejný kód, autora nebo jen představu o formátu, se mohou na chybném porozumění shodovat donekonečna; nezávislý validátor tuto symetrii naruší. Před vydáním verze proto nechte svůj podepsaný výstup projít alespoň jedním externím nástrojem pro kontrolu shody a v sestavení ponechte rychlou strukturální sebekontrolu jako první linii obrany — metoda TPdf.ValidatePades popsaná v článku o kontrole podpisů hlásí selhání pokrytí jako konkrétní pojmenovaný problém

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;

Kontrolní seznam při odmítnutí podepsaného výstupu

Pokud platforma odmítne váš podpis PAdES, zkontrolujte nejprve tyto snadno ověřitelné strukturální příčiny, než začnete mít podezření na certifikáty nebo řetězce důvěry. Přečtěte pole /ByteRange a ověřte tři rovnosti: první hodnota je nula, druhý rozsah začíná na konci prvního nebo za ním, a druhý rozsah končí přesně na konci souboru. Načtěte položku /M a ověřte, zda jde o kompletní řetězec data podle §7.9.4 s předponou D: a značkou časového posunu. Ujistěte se, že zdrojový dokument nebyl při podepisování šifrován — a pokud jej váš podepisovací nástroj přesto bez upozornění podepsal, považujte toto mlčení za chybu v knihovně. Všechny tři kontroly proběhnou nad surovými bajty během milisekund a podle našich zkušeností vysvětlují odmítnutí mnohem častěji než jakákoli kryptografická příčina

Zde použitá volání pro podepisování, kontrolu a validaci — SignPades, ValidatePades a kontroly shody PAdES s opravenou matematikou ByteRange, formátováním data a blokováním šifrování — jsou součástí produktu PDFium Component pro Delphi, C++Builder a Lazarus