Articol tehnic

Crearea unui banc de testare pentru conformitate și semnare în Delphi cu PDF Library for Delphi

Un banc de lucru care leagă validarea de conformitate de semnarea digitală trebuie să coordoneze patru pași, exact în această ordine, și să îi țină legați de un singur set de octeți pe tot parcursul. Rulează un preflight PDF/A sau PDF/UA. Aplică remedierile cerute de constatări și salvează o revizie corectată. Semnează exact acea revizie. Apoi recitește fișierul semnat și confirmă că semnătura chiar îl acoperă. Ordinea nu este una cosmetică. Dacă sari peste recitire, te bazezi pe propria cale de scriere; dacă lași preflight-ul să ruleze pe revizia greșită, raportul tău de conformitate descrie un fișier pe care nu l-ai livrat niciodată

Partea pe care majoritatea pipeline-urilor făcute în casă o greșesc este îmbinarea dintre validare și semnare. Rulează-le ca două unelte separate, cu o etapă de remediere la mijloc, și vor lua naștere cel puțin trei revizii distincte ale fișierului, fiecare cu octeții ei. Raportul de preflight pe care îl dai auditorului descrie una dintre ele. Semnătura îngheață alta. Nimic din fișier nu spune că sunt aceeași revizie și de multe ori chiar nu sunt. PDF Library for Delphi, biblioteca losLab PDF Developer Library pentru Delphi și C++Builder, așază preflight-ul și semnarea PAdES în spatele unei singure clase de fațadă, astfel încât întreaga secvență poate trăi într-un singur proces care nu pierde niciodată din vedere despre ce octeți vorbește. Fiecare apel de mai jos există în bibliotecă astăzi, la fel și fiecare capcană semnalată alături de el

Diagramă a unui banc de lucru Delphi pentru conformitate și semnare, în care pașii de preflight, remediere, semnare PAdES și audit ByteRange înregistrează fiecare un SHA-256 peste revizia exactă pe care o ating
Hashurile înregistrate lângă fiecare salvare leagă raportul de preflight, semnătura PAdES și auditul de o singură revizie identică

Trei revizii ale aceluiași document și cum se deschide decalajul

Numără salvările. Originalul vine din amonte. Etapa de remediere îl încarcă, activează un mod de conformitate și scrie o revizie corectată. Etapa de semnare adaugă semnătura ca actualizare incrementală, adică o a treia scriere. Trei salvări, trei aranjamente de octeți, iar un raport de preflight nu înseamnă nimic dacă nu spune pe care dintre cele trei o acoperă. Un SHA-256 al fișierului, înregistrat lângă fiecare rulare de preflight și fiecare semnătură, este ancora ieftină care îți permite să dovedești că revizia validată este chiar revizia semnată

Un comportament al bibliotecii strânge și mai mult această disciplină. Remedierile de conformitate cerute prin SetPDFAMode sau SetPDFUAMode nu intră în vigoare în momentul apelului. Ele sunt aplicate în timpul salvării. Reparațiile automate — precum forțarea fanioanelor de tipărire ale adnotărilor sau atribuirea unei ordini de tabulare PDF/UA — ajung în fișierul de ieșire și nicăieri altundeva, așa că o verificare rulată pe documentul pe care tocmai l-ai „reparat” în memorie nu îți spune nimic despre octeții care pleacă spre semnare. Salvează întâi, apoi rulează preflight pe fișierul salvat. Starea din memorie este o ciornă; doar fișierul de pe disc este real

Preflight de pe disc și zeroul care înseamnă două lucruri

Punctul de intrare plat pentru preflight este CheckFileCompliance(FileName, Password, ComplianceTest, Options). Testul 1 selectează PDF/A (ISO 19005), testul 2 selectează PDF/UA (ISO 14289). Deschide fișierul prin cititorul în flux al bibliotecii, deci nu este nevoie de LoadFromFile înainte, și întoarce un handle de listă de șiruri care poartă câte o constatare pe intrare:

var
  PDF: TPDFlib;
  ListID, I: Integer;
begin
  PDF := TPDFlib.Create;
  try
    ListID := PDF.CheckFileCompliance('invoice-fixed.pdf', '', 1, 0);  // 1 = PDF/A
    if ListID = 0 then
    begin
      if PDF.LastErrorCode <> 0 then
        raise Exception.Create('Preflight could not read the file')
      else
        Writeln('No PDF/A findings');
    end
    else
    begin
      for I := 0 to PDF.GetStringListCount(ListID) - 1 do
        Writeln(PDF.GetStringListItem(ListID, I));
      PDF.ReleaseStringList(ListID);
    end;
  finally
    PDF.Free;
  end;
end;

Capcana stă în valoarea returnată și este exact genul care trece orice test pe traseul fericit. Zero înseamnă „nicio constatare”. Zero mai înseamnă și „fișierul nu a putut fi deschis”, fiindcă implementarea întoarce 0 ori de câte ori lista de rezultate vine goală, inclusiv la o eroare de citire. Un banc de lucru care citește 0 ca undă verde va aproba senin un fișier pe care alt proces îl ține blocat. Împerecherea apelului cu LastErrorCode, ca mai sus, este ceea ce separă cele două cazuri. Verificatorul deschide fișierul și cu un mod de partajare care refuză scrierea, așa că, dacă etapa ta de remediere încă ține un handle de scriere, preflight-ul eșuează dintr-un motiv care nu are nimic de-a face cu conformitatea și totul de-a face cu un flux pe care ai uitat să îl eliberezi

Diagramă decizională care arată cum LastErrorCode separă cele două sensuri ale unei returnări zero din CheckFileCompliance într-un preflight PDF Delphi
Un zero de la CheckFileCompliance nu înseamnă nimic până când LastErrorCode nu separă o listă goală de constatări de un fișier pe care biblioteca nu a putut să-l deschidă

Când constatările trebuie citite de un om, nu de un pipeline, CreatePreflightReport le redă sub forma unui raport lizibil. ComparePreflightReports compară două rulări, o cale curată de a arăta că remedierea a eliminat constatările inițiale fără să introducă în tăcere altele noi

Semnarea reviziei verificate cu un SignProcess

După ce revizia salvată trece de preflight, iar amprenta ei este consemnată, semnează exact acel fișier și niciun altul. API-ul SignProcess se citește ca un builder. Deschizi un handle de proces, îl configurezi linie cu linie, îl finalizezi, apoi citești codul de rezultat

ProcessID := PDF.NewSignProcessFromFile('invoice-fixed.pdf', '');
if ProcessID = 0 then
  raise Exception.Create('Cannot open source for signing');
PDF.SetSignProcessField(ProcessID, 'ApprovalSig');
PDF.SetSignProcessPFXFromFile(ProcessID, 'company.pfx', PfxPassword);
PDF.SetSignProcessInfo(ProcessID, 'Invoice approval', 'Berlin', 'billing@example.com');
PDF.SetSignProcessCustomSubFilter(ProcessID, 'ETSI.CAdES.detached');  // baseline PAdES
PDF.SetSignProcessDigestAlgorithm(ProcessID, 2);                      // SHA-256
PDF.SetSignProcessReserveContentsBytes(ProcessID, 8192);              // loc pentru o marcă temporală ulterioară
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
  Writeln('Sign failed, code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);

Două linii din acea secvență cântăresc mai mult decât par. SetSignProcessCustomSubFilter cu ETSI.CAdES.detached alege o semnătură PAdES așa cum este profilată în ETSI EN 319 142-1, în locul familiei vechi adbe.pkcs7.detached, iar aici stă diferența dintre o semnătură pe care un validator european o acceptă și una pe care o semnalează. SetSignProcessReserveContentsBytes dimensionează spațiul rezervat pentru /Contents, iar mărimea aleasă acum este o decizie despre viitor: dacă va urma vreodată o marcă temporală de semnătură, CMS-ul mărit trebuie să încapă în spațiul rezervat astăzi, fiindcă acel spațiu nu mai poate crește ulterior fără resemnarea întregului document. Rezervă generos și pierzi câțiva kiloocteți. Rezervă prea strâns și etapa de marcare temporală eșuează peste luni de zile cu o depășire pe care cu greu o vei mai lega de această singură linie

GetSignProcessResult răspunde cu un cod, nu cu un boolean, iar codurile merită păstrate. 1 înseamnă succes. 4 este o parolă PDF greșită, 7 o parolă de certificat greșită, 9 un PFX care nu poartă cheie privată, 11 o eroare apărută în timp ce semnătura era aplicată. Comprimă-le într-un true/false și arunci singura informație care deosebește un caz de suport cu parolă greșită de unul cu o cheie fără parte privată. Înregistrează valoarea întreagă în jurnal

Recitirea: auditarea fișierului tocmai produs

Niciun banc de lucru nu ar trebui să aibă încredere în calea care a scris fișierul pe care urmează să îl certifice. Clasa de audit TPDFlibSignDoc redeschide ieșirea semnată și citește intrările dicționarului de semnătură direct de pe disc:

var
  Doc: TPDFlibSignDoc;
  Names: TStringList;
  FS: TFileStream;
  I: Integer;
  SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
  // Reține dimensiunea înainte de Open: obiectul de audit ține fișierul sub share lock
  FS := TFileStream.Create('invoice-signed.pdf', fmOpenRead or fmShareDenyNone);
  SourceSize := FS.Size;
  FS.Free;
  Doc := TPDFlibSignDoc.Create;
  Names := TStringList.Create;
  try
    if not Doc.Open('invoice-signed.pdf', '', False) then Exit;
    Doc.GetSignatureFieldNames(Names);
    for I := 0 to Names.Count - 1 do
      if Doc.GetSignatureValueObjNum(Names[I]) > 0 then  // > 0 înseamnă că acel câmp este semnat
      begin
        RangeStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
        GapStart   := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
        TailStart  := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
        TailLen    := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
        if (RangeStart = 0) and (TailStart + TailLen = SourceSize) then
          Writeln(Names[I], ': signature covers the file to EOF')
        else
          Writeln(Names[I], ': earlier revision, or unusual ByteRange layout');
      end;
    Doc.Close;
  finally
    Names.Free;
    Doc.Free;
  end;
end;

Argumentele ValueKey corespund intrărilor din dicționar. Cheia 0 întoarce CMS-ul brut din /Contents, cheile 2 și 3 numele /Filter și /SubFilter, iar 11 până la 14 cele patru numere din ByteRange. Valorile text vin în schimb prin GetSignatureTextValueByName: cheia 0 este momentul declarat al semnării, iar cheia 5 deosebește un Sig obișnuit de un DocTimeStamp, lucru care contează de îndată ce un document le poartă pe amândouă

Capturarea dimensiunii fișierului de la începutul acelui exemplu este de rezistență, nu treabă de menaj. TPDFlibSignDoc.Open ține fișierul sub un share lock restrictiv pe toată durata sa de viață, așa că orice are nevoie de octeții bruți (calcularea amprentei intervalului semnat, recalcularea digestului CMS) trebuie să citească fișierul înainte de apelul Open. Chiar demonstrația SigningWorkbench a bibliotecii citește mai întâi întregul fișier în memorie tocmai din acest motiv, iar un banc de lucru care ignoră ordinea eșuează intermitent, pe oricare mașină se întâmplă să piardă cursa

Aritmetica ByteRange care dovedește acoperirea

Un fișier sănătos cu o singură semnătură are un ByteRange de forma [0 a b c]: acoperirea începe la offsetul 0, sare peste spațiul hexazecimal rezervat pentru /Contents dintre a și b, apoi continuă până la octetul b+c. Când b+c este egal cu dimensiunea fișierului, semnătura acoperă totul până la sfârșitul fișierului, adică exact rezultatul dorit. Când rămâne în urmă, cineva a adăugat o actualizare incrementală după scrierea semnăturii. Acest lucru este perfect legitim conform ISO 32000-1§12.8, întrucât completările ulterioare de formular, o a doua semnătură și un dicționar DSS sosesc exact pe această cale. Este totodată chiar faptul pe care o pistă de audit ar trebui să îl consemneze la momentul semnării, nu să îl reconstituie sub presiune în timpul unui litigiu

PDF Library for Delphi: anatomia ByteRange a unui PDF semnat, arătând golul substituent Contents plus un caz de acoperire completă și un caz de actualizare incrementală adăugată
Un ByteRange 0 a b c acoperă fișierul doar când b + c ajunge la sfârșitul fișierului, astfel încât auditul înregistrează orice actualizare incrementală adăugată după semnare

Fii atent la lățimea întregilor cât timp faci această aritmetică. GetSignProcessByteRange din API-ul plat întoarce un Integer pe 32 de biți, dar valorile din spate sunt Int64, așa că pe un fișier de peste 2 GB accesorul plat trunchiază în tăcere. Folosește TPDFlibSigner.GetByteRange din stratul de clase, care întoarce Int64, sau extrage valorile din GetSignatureValueByName așa cum face codul de audit de mai sus

Ce lasă biblioteca în seama ta

Două limite se învață mai bine în faza de proiectare decât în sprintul final. API-ul plat TPDFlib nu conține niciun wrapper pentru verificarea semnăturilor. Verificarea criptografică stă cu un strat mai jos, în TPDFlibSignatureVerifier, al cărui VerifySignature răspunde valid, invalid sau necunoscut. Nu există niciun client HTTP încorporat pentru autoritățile de marcare temporală RFC 3161. Biblioteca calculează amprenta de trimis și reîncorporează CMS-ul augmentat după ce sosește un token, dar drumul dus-întors prin rețea până la TSA rămâne în sarcina ta. Ambele sunt simplu de împachetat și cu adevărat neplăcute de descoperit lipsă cu o săptămână înainte de lansare, așa că proiectează-le încă din prima schiță

O întrebare despre conformitate merită lămurită pe față, fiindcă ea decide unde se așază ultima poartă: adăugarea unei semnături strică PDF/A? Nu prin ea însăși. Semnătura sosește ca actualizare incrementală, iar ISO 19005-2 și versiunile ulterioare permit explicit documentele semnate. Nuanța este aspectul semnăturii, care joacă după aceleași reguli ca orice alt conținut de pagină, inclusiv fonturi încorporate și absența culorilor dependente de dispozitiv. Așadar, ultima poartă a bancului de lucru este încă o rulare de preflight, de data aceasta pe ieșirea semnată. Tratează CheckFileCompliance drept verificarea rapidă din pipeline și validează totuși candidații de lansare cu o unealtă independentă precum veraPDF, fiindcă validatoarele implementează seturi de reguli care se suprapun, dar nu sunt identice; când cele două nu sunt de acord, textul constatării numește de obicei clauza care trebuie citită

Din toate acestea rezultă un punct legat de succesiune. Semnarea și marcarea temporală nu sunt o singură trecere: mai întâi este scrisă semnătura de bază, apoi un proces separat de marcare temporală mărește CMS-ul în interiorul spațiului /Contents rezervat, exact motivul pentru care linia cu octeții rezervați cântărea atât de mult mai devreme. Pentru straturile de marcare temporală și de validare pe termen lung care se construiesc peste acest banc de lucru, parcursul de semnare și validare PAdES duce semnătura de la baseline la B-LT, iar jumătatea de preflight este aprofundată în ghidul de preflight PDF/A și PDF/UA. Documentația API completă și descărcările de probă se află pe pagina de produs PDF Library for Delphi