Artykuł techniczny

Polityka ECDSA i EdDSA wg ISO/TS 32002 w Delphi

PDFium Component dla Delphi sprawdza każdy podpis PDF ECDSA i EdDSA względem profilu algorytmicznego ISO/TS 32002 i zgłasza werdykt w TPadesSignatureValidation.AlgorithmPolicyStatus. Przejdź mogą tylko P-256, P-384, P-521, trzy krzywe Brainpool r1, Ed25519 i Ed448, każda z pasującym digestem, a rozjazd podnosi ppeiSignatureAlgorithmMismatch. Ta polityka znaczy więcej, niż brzmi. Podpis na brainpoolP160r1 albo klucz P-256 podpisujący digest SHA-512 może się weryfikować świetnie na poziomie matematyki, więc Windows CryptoAPI zgłasza wartość podpisu jako dobrą, podczas gdy surowy walidator PDF 2.0 odrzuca plik. Kontrola polityki zamyka tę lukę i jest celowo oddzielona od pytania, czy bajty podpisu są kryptograficznie poprawne

Co ISO/TS 32002 faktycznie dopuszcza dla podpisów na krzywych eliptycznych?

ISO/TS 32002 dopuszcza w podpisach PDF dokładnie sześć krzywych ECDSA i dwa schematy EdDSA i wiże każdą krzywą z rozmiarami digestu, jakie może nieść. PDFium Component koduje tę tabelę w PadesCurveDigestAllowed, kluczowanej OID-em krzywej z certyfikatu podpisującego. Krzywe NIST są surowe: digest musi mieć tę samą szerokość w bitach co krzywa, SHA-2 albo SHA-3. Krzywe Brainpool są łagodniejsze i akceptują swoją szerokość albo cokolwiek szerszego:

  • P-256 (1.2.840.10045.3.1.7): tylko SHA-256 albo SHA3-256
  • P-384 (1.3.132.0.34): tylko SHA-384 albo SHA3-384
  • P-521 (1.3.132.0.35): tylko SHA-512 albo SHA3-512
  • brainpoolP256r1 (1.3.36.3.3.2.8.1.1.7): dowolny digest SHA-2 albo SHA-3 od 256 do 512 bitów
  • brainpoolP384r1 (1.3.36.3.3.2.8.1.1.11): SHA-2 / SHA-3 o 384 lub 512 bitach
  • brainpoolP512r1 (1.3.36.3.3.2.8.1.1.13): tylko SHA-512 albo SHA3-512
Matryca profilu algorytmicznego ISO TS 32002 w PDFium Component: P-256, P-384 i P-521 akceptują tylko pasujące szerokości digestu w PadesCurveDigestAllowed, brainpoolP256r1 akceptuje 256 do 512 bitów, brainpoolP384r1 akceptuje 384 i 512, Ed25519 i Ed448 deklarują SHA-512 i SHAKE256 z długością 512, a rozjazdy podnoszą ppeiSignatureAlgorithmMismatch, podczas gdy krzywe poza profilem zwracają pcsUnsupported
Przejdź może sześć krzywych ECDSA i dwa schematy EdDSA, a każda krzywa jest związana z szerokościami digestu, jakie może nieść; wszystko inne jest niepoprawne albo niewspierane, nigdy po cichu akceptowane

EdDSA nie ma wyboru krzywej ani digestu i dokładnie dlatego jego reguły dotyczą kodowania, nie siły. Zgodnie z RFC 8419 SignerInfo Ed25519 musi zadeklarować SHA-512 jako swój digestAlgorithm bez parametrów, a SignerInfo Ed448, na ścieżce signed-attributes, którą PAdES zawsze używa, musi zadeklarować id-shake256-len (2.16.840.1.101.3.4.2.18) z parametrem INTEGER równym dokładnie 512. Dla obu schematów AlgorithmIdentifier podpisu i AlgorithmIdentifier klucza publicznego certyfikatu nie mogą nieść żadnych parametrów. Producent, który wpisze tam NULL — nawyk, w który enkodery RSA wytrenowały wiele bibliotek ASN.1 — produkuje niezgodny podpis, choć klucz i wartość podpisu są w porządku

Jak PDFium Component wyciąga trójkę algorytmiczną z CMS

Sam PDFium nie odpowie na to pytanie, bo jego publiczne API podpisów czyta słownik podpisu, ale ani nie weryfikuje CMS, ani nie wystawia krzywej certyfikatu podpisującego. Warstwa inspekcji PAdES zbudowana na PDFium parsuje więc sama CMS SignedData (RFC 5652). InspectPadesSignatureAlgorithm czyta digestAlgorithm i signatureAlgorithm pierwszego SignerInfo, potem znajduje certyfikat podpisującego i czyta jego SubjectPublicKeyInfo, żeby dostać algorytm klucza i krzywą. Wyszukiwanie certyfikatu jest celowo ograniczone: badane jest co najwyżej 64 certyfikatów w zbiorze certificates CMS, dopasowanie to porównanie bajt po bajcie wystawcy i numeru seryjnego z issuerAndSerialNumber, a kod cofa się do "jedynego certyfikatu, jaki tam jest" tylko wtedy, gdy zbiór trzyma dokładnie jeden parsowalny certyfikat. Wybranie pierwszego certyfikatu EC z nieuporządkowanego zbioru byłoby łatwe i pozwoliłoby certyfikatowi CA zdecydować, jakiej krzywej rzekomo użył podpisujący

Jak PDFium Component inspekcjonuje trójkę algorytmiczną podpisu PDF: InspectPadesSignatureAlgorithm czyta digestAlgorithm i signatureAlgorithm pierwszego SignerInfo w CMS, przypina certyfikat podpisującego przez dokładne dopasowanie issuerAndSerialNumber wśród co najwyżej 64 kandydatów, czyta SubjectPublicKeyInfo dla krzywej, a EvaluatePadesSignatureAlgorithm zwraca AlgorithmPolicyStatus
Sam PDFium ani nie weryfikuje CMS, ani nie wystawia krzywej podpisującego, więc warstwa PAdES parsuje SignedData i trzyma każdy surowy OID w rekordzie, żeby odrzucenie dawało się wyjaśnić
uses
  PDFium, FPdfPades;

const
  StatusNames: array[TPadesCryptoStatus] of string =
    ('not checked', 'valid', 'invalid', 'unsupported', 'indeterminate');

var
  Pdf: TPdf;
  R: TPadesValidationResult;
  A: TPadesSignatureAlgorithmInfo;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'invoice-ecdsa-signed.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePades;
    for I := 0 to Length(R.Signatures) - 1 do
    begin
      A := R.Signatures[I].AlgorithmInfo;
      Writeln('Signature ', I);
      Writeln('  digestAlgorithm    : ', string(A.DigestAlgorithmOid));
      Writeln('  signatureAlgorithm : ', string(A.SignatureAlgorithmOid));
      Writeln('  public key / curve : ', string(A.PublicKeyAlgorithmOid),
        ' / ', string(A.CurveOid));
      Writeln('  policy             : ',
        StatusNames[R.Signatures[I].AlgorithmPolicyStatus]);
    end;
    if ppeiSignatureAlgorithmMismatch in R.Issues then
      Writeln('At least one signature violates the ISO/TS 32002 profile');
  finally
    Pdf.Free;
  end;
end;

TPdf.ValidatePades stosuje politykę w ramach swojego przebiegu zgodności, startując od certyfikatu, który zlokalizuje wewnątrz CMS, a TPdf.ValidatePadesTrust powtarza ją wobec certyfikatu podpisującego, którego Windows CryptoAPI faktycznie użył do weryfikacji, więc certyfikat zgłoszony przez CryptoAPI ma ostatnie słowo. Każde surowe wejście ląduje w TPadesSignatureAlgorithmInfo, łącznie z DigestParametersPresent, DigestParameterBits, SignatureParametersPresent i PublicKeyParametersAreNamedCurve, więc odrzucenie zawsze daje się wyjaśnić z rekordu, a nie z linii logu

Dlaczego podpis P-256 z SHA3-256 wywala politykę?

Podpis P-256 wywala politykę PDFium Component za każdym razem, gdy CMS digestAlgorithm i digest wynikający z ECDSA signatureAlgorithm są w rozjeździe, nawet jeśli oba z osobna są dopuszczalne dla krzywej. EvaluatePadesSignatureAlgorithm najpierw mapuje ecdsa-with-SHA256, ecdsa-with-SHA3-256 i ich odpowiedniki na digest, porównuje to z zadeklarowanym digestAlgorithm i zwraca pcsInvalid przy każdej różnicy, zanim sięgnie po tabelę krzywych. Przypadek jest realny: narzędzie podpisujące przełącza swój hash na SHA3-256, ale trzyma zaszyty na sztywno identyfikator ecdsa-with-SHA256, a efektem jest plik, którego żaden zgodny weryfikator nie zinterpretuje spójnie. Funkcja jest publiczna, więc matrycę można przypiąć w teście jednostkowym bez budowania PDF-a:

var
  Info: TPadesSignatureAlgorithmInfo;
begin
  Info := Default(TPadesSignatureAlgorithmInfo);
  Info.Family := psafEcdsa;
  Info.PublicKeyAlgorithmOid := '1.2.840.10045.2.1';      // id-ecPublicKey
  Info.PublicKeyParametersPresent := True;
  Info.PublicKeyParametersAreNamedCurve := True;
  Info.CurveOid := '1.2.840.10045.3.1.7';                 // P-256
  Info.DigestAlgorithmOid := '2.16.840.1.101.3.4.2.8';    // SHA3-256
  Info.SignatureAlgorithmOid := '2.16.840.1.101.3.4.3.10'; // ecdsa-with-SHA3-256
  Assert(EvaluatePadesSignatureAlgorithm(Info) = pcsValid);

  Info.SignatureAlgorithmOid := '1.2.840.10045.4.3.2';    // ecdsa-with-SHA256
  Assert(EvaluatePadesSignatureAlgorithm(Info) = pcsInvalid); // rozjazd digestu

  Info.CurveOid := '1.3.36.3.3.2.8.1.1.1';                // brainpoolP160r1
  Info.DigestAlgorithmOid := '2.16.840.1.101.3.4.2.1';    // SHA-256, teraz zgodne
  Assert(EvaluatePadesSignatureAlgorithm(Info) = pcsUnsupported); // krzywa poza profilem
end;

Kodowanie krzywej dostaje tę samą surowość. RFC 5480 §2.1.1 pozwala, by ECParameters było OID-em nazwanej krzywej, krzywą niejawną (NULL) albo pełnym jawnym zestawem parametrów, a profile PKIX wymagają formy nazwanej. PDFium Component zwraca pcsInvalid, gdy certyfikat id-ecPublicKey niesie brak parametrów, parametry niejawne albo parametry jawne, bo jawne parametry pozwalają atakującemu opisać krzywą, która jedynie przypomina standardową. Poprawnie nazwana krzywa, której po prostu nie ma na liście ISO/TS 32002, jak brainpoolP160r1 wyżej albo secp256k1, dostaje zamiast tego pcsUnsupported

Invalid, unsupported czy indeterminate: uczciwa lektura statusu

Trzy statusy spoza pcsValid w AlgorithmPolicyStatus znaczą różne rzeczy, a zlanie ich w jedno wiadro "nie przeszło" wyrzuca informację, której potrzebują audytorzy. pcsInvalid znaczy, że rozpoznana kombinacja algorytmów jest zniekształcona albo w rozjeździe; dodaje ppeiSignatureAlgorithmMismatch do TPadesValidationResult.Issues i pędzi zagregowany IntegrityStatus do pcsInvalid, więc IsCryptographicallyValid zwraca False, nawet gdy wartość podpisu CMS się zgadza. pcsUnsupported znaczy, że krzywa albo digest są poza tym, co wymienia profil — to wynik możliwości, nie dowód manipulacji. pcsIndeterminate znaczy, że nie udało się przypiąć certyfikatu podpisującego, zwykle CertificateSet z kilkoma kandydatami i bez dokładnego trafienia w issuerAndSerialNumber, więc kod odmawia zgadywania krzywej; od v3.124.0 oznacza też podpis RSA nad SHA-1 albo digestem 112-bitowym, jak SHA-224, który nie jest już uzgodniony dla bieżącej walidacji. Ten sam podział dotyczy EdDSA na maszynie, której CryptoAPI nie umie zweryfikować Ed25519 ani Ed448: CmsSignatureStatus zostaje pcsUnsupported, podczas gdy AlgorithmPolicyStatus może być nadal pcsValid, bo kodowanie było poprawne, a zabrakło tylko weryfikatora. Jeśli goniisz odrzucenie od Adobea albo walidatora opartego na DSS, przewodnik po tym, dlaczego walidatory odrzucają podpisy PAdES pokrywa pozostałe częste przyczyny

Ścieżka decyzyjna EvaluatePadesSignatureAlgorithm w PDFium Component: rozjazd digestu ustawia pcsInvalid i ppeiSignatureAlgorithmMismatch, nazwana krzywa poza profilem ISO TS 32002, jak brainpoolP160r1, ustawia pcsUnsupported, certyfikat podpisującego, którego nie da się przypiąć, ustawia pcsIndeterminate, a od v3.124.0 gałąź RSA stosuje zestawy digestów ETSI TS 119 312, z rozmiarem klucza nadal zostawionym aplikacji
Trzy statusy spoza pcsValid znaczą różne rzeczy: invalid to dowód popsuanej kombinacji, unsupported to wynik możliwości, a indeterminate znaczy, że kod odmówił zgadywania
var
  Pdf: TPdf;
  Options: TPadesTrustValidationOptions;
  R: TPadesValidationResult;
  S: TPadesSignatureValidation;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'invoice-ecdsa-signed.pdf';
    Pdf.Active := True;
    Options := TPadesTrustValidationOptions.Default; // offline, bez rewokacji
    R := Pdf.ValidatePadesTrust(Options);
    for I := 0 to Length(R.Signatures) - 1 do
    begin
      S := R.Signatures[I];
      if S.IsDocumentTimeStamp then
        Continue;
      case S.AlgorithmPolicyStatus of
        pcsInvalid:       Writeln(I, ': reject, algorithm combination is invalid');
        pcsUnsupported:   Writeln(I, ': manual review, curve or digest outside the profile');
        pcsIndeterminate: Writeln(I, ': manual review, signer not identified or digest no longer agreed');
        pcsValid:
          if S.AlgorithmInfo.Family = psafRsa then
            Writeln(I, ': RSA digest suite accepted, check key size yourself')
          else
            Writeln(I, ': EC/EdDSA profile satisfied');
      end;
    end;
    Writeln('Cryptographically valid: ', R.IsCryptographicallyValid);
  finally
    Pdf.Free;
  end;
end;

Czego pcsValid nie gwarantuje?

AlgorithmPolicyStatus = pcsValid poświadcza tylko, że podpis ECDSA albo EdDSA używa zaakceptowanej krzywej ze zgodnym, poprawnie zakodowanym digestem, a podpis RSA — digestu z bieżącego zestawu; o poprawności wartości podpisu nie mówi nic. Przed v3.124.0 gałąź RSA EvaluatePadesSignatureAlgorithm była celowo szeroka: każdy signatureAlgorithm pod łukiem PKCS #1 1.2.840.113549.1.1.* zwracał pcsValid, w tym wiekowe sha1WithRSAEncryption. Od PDFiumPas v3.124.0 gałąź RSA stosuje zestawy podpisowe ETSI TS 119 312. Digesty MD2, MD4 i MD5 to pcsInvalid. SHA-1 i digesty 112-bitowe, jak SHA-224, to pcsIndeterminate, więc podpis SHA-1 zachowuje swój ogólny wynik integralności i jest flagowany do przeglądu, zamiast być odrzucany. digestAlgorithm różniący się od digestu ustalonego przez algorytm podpisu, jak sha256WithRSAEncryption nad digestem SHA-1, albo algorytm podpisu RSA na kluczu podpisującego innej rodziny, to pcsInvalid, raportowany jako ppeiSignatureAlgorithmMismatch i wywalający integralność. Certyfikat podpisującego, którego nie da się znaleźć, daje pcsIndeterminate, jak już dawał dla ECDSA, a nierozpoznane digesty albo OID-y RSA spoza podpisów dają pcsUnsupported. Długość modulusa nadal nie jest sprawdzana, parametry PSS nie są tu walidowane (artykuł o RSASSA-PSS-params z RFC 4055 opisuje, jak są kodowane po stronie podpisującej), a digesty SHA-1 albo MD5 dodatkowo podnoszą osobny problem ppeiBadDigestAlgorithm. Podobnie matematyka podpisu, łańcuch certyfikatów i rewokacja pozostają robotą CmsSignatureStatus, CertificateTrustStatus i RevocationStatus, które pochodzą z Windows CryptoAPI. Traktuj pcsValid jako "profil algorytmiczny się trzyma", nigdy jako "ten klucz jest wystarczająco silny"

Dla aplikacji Delphi przyjmującej podpisane faktury PDF, umowy albo paczki archiwalne praktyczna konfiguracja jest krótka: odpal ValidatePadesTrust, odrzucaj przy ppeiSignatureAlgorithmMismatch, kieruj pcsUnsupported i pcsIndeterminate do człowieka i egzekwuj własne minimum rozmiaru klucza RSA, bo polityka tego nie zrobi. PDFium Component dla Delphi i Lazarusa dowozi walidator PAdES, budowniczego raportu dowodów i pipeline podpisujący, więc ta sama biblioteka może te podpisy produkować i sprawdzać od końca do końca