Технічна стаття

ECDSA і EdDSA за ISO/TS 32002: перевірки в PDFium Delphi

PDFium Component для Delphi перевіряє кожен підпис PDF ECDSA й EdDSA за профілем алгоритмів ISO/TS 32002 і звітує вердикт у TPadesSignatureValidation.AlgorithmPolicyStatus. Пройти можуть лише P-256, P-384, P-521, три криві Brainpool r1, Ed25519 і Ed448, кожна з парним дайджестом, а розбіжність підіймає ppeiSignatureAlgorithmMismatch. Ця політика важить більше, ніж звучить. Підпис на brainpoolP160r1, або ключ P-256, що підписує дайджест SHA-512, може перевірятися цілком гаразд на рівні математики, тож Windows CryptoAPI звітує значення підпису як добре, поки суворий валідатор PDF 2.0 відкидає файл. Перевірка політики закриває той розрив, і вона навмисно відокремлена від питання, чи байти підпису криптографічно коректні

Що ISO/TS 32002 насправді дозволяє для підписів на еліптичних кривих?

ISO/TS 32002 дозволяє рівно шість кривих ECDSA і дві схеми EdDSA у підписах PDF і прив'язує кожну криву до розмірів дайджесту, які вона може нести. PDFium Component кодує ту таблицю в PadesCurveDigestAllowed, з ключем за OID кривої з сертифіката підписувача. Криві NIST суворі: дайджест мусить мати ту саму бітову ширину, що й крива, або SHA-2, або SHA-3. Криві Brainpool поблажливіші й приймають власну ширину чи будь-що ширше:

  • P-256 (1.2.840.10045.3.1.7): лише SHA-256 або SHA3-256
  • P-384 (1.3.132.0.34): лише SHA-384 або SHA3-384
  • P-521 (1.3.132.0.35): лише SHA-512 або SHA3-512
  • brainpoolP256r1 (1.3.36.3.3.2.8.1.1.7): будь-який дайджест SHA-2 чи SHA-3 від 256 до 512 бітів
  • brainpoolP384r1 (1.3.36.3.3.2.8.1.1.11): SHA-2 / SHA-3 на 384 або 512 бітів
  • brainpoolP512r1 (1.3.36.3.3.2.8.1.1.13): лише SHA-512 або SHA3-512
Матриця профілю алгоритмів ISO TS 32002 у PDFium Component: P-256, P-384 і P-521 приймають лише парні ширини дайджестів у PadesCurveDigestAllowed, brainpoolP256r1 приймає 256–512 бітів, brainpoolP384r1 — 384 і 512, Ed25519 і Ed448 заявляють SHA-512 і SHAKE256 із довжиною 512, розбіжності підіймають ppeiSignatureAlgorithmMismatch, а криві поза профілем повертають pcsUnsupported
Пройти можуть шість кривих ECDSA і дві схеми EdDSA, і кожна крива прив'язана до ширин дайджестів, які може нести; усе інше — invalid або unsupported, ніколи не мовчки прийняте

EdDSA не має вибору кривої і вибору дайджесту — саме тому його правила про кодування, а не про міць. За RFC 8419, Ed25519 SignerInfo мусить заявити SHA-512 як свій digestAlgorithm без параметрів, а Ed448 SignerInfo, на шляху signed-attributes, який PAdES завжди використовує, мусить заявити id-shake256-len (2.16.840.1.101.3.4.2.18) з INTEGER-параметром рівно 512. Для обох схем AlgorithmIdentifier підпису і AlgorithmIdentifier відкритого ключа сертифіката мусять бути взагалі без параметрів. Продюсер, що пише там NULL — звичка, яку RSA-енкодери прищепили багатьом бібліотекам ASN.1, — видає невідповідний підпис, хоча ключ і значення підпису гаразд

Як PDFium Component витягує трійку алгоритмів із CMS

Сам PDFium не може відповісти на це питання, бо його публічний API підписів читає словник підпису, але ані не верифікує CMS, ані не виставляє криву сертифіката підписувача. Тому шар інспекції PAdES, збудований на PDFium, сам парсить CMS SignedData (RFC 5652). InspectPadesSignatureAlgorithm читає digestAlgorithm і signatureAlgorithm першого SignerInfo, потім знаходить сертифікат підписувача і читає його SubjectPublicKeyInfo, щоб дістати алгоритм ключа і криву. Пошук сертифіката навмисно обмежений: розглядається щонайбільше 64 сертифікати з множини certificates CMS, збіг — точне побайтове порівняння issuer і серійного номера з issuerAndSerialNumber, а код падає назад до «єдиного сертифіката, який там є», лише коли множина тримає рівно один парсабельний сертифікат. Схопити перший EC-сертифікат з невпорядкованої множини було б легко, і це дозволило б CA-сертифікату вирішувати, яку криву нібито використав підписувач

Як PDFium Component інспектує трійку алгоритмів підпису PDF: InspectPadesSignatureAlgorithm читає digestAlgorithm і signatureAlgorithm першого SignerInfo в CMS, пришпилює сертифікат підписувача через точний збіг issuerAndSerialNumber поміж щонайбільше 64 кандидатами, читає SubjectPublicKeyInfo заради кривої, а EvaluatePadesSignatureAlgorithm повертає AlgorithmPolicyStatus
Сам PDFium ані не верифікує CMS, ані не виставляє криву підписувача, тож шар PAdES парсить SignedData і тримає кожен сирий OID у записі ради пояснюваного відхилення
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 застосовує політику як частину свого проходу відповідності, стартуючи від сертифіката, який він знайде всередині CMS, а TPdf.ValidatePadesTrust повторно проганяє її проти сертифіката підписувача, який Windows CryptoAPI справді використав для верифікації, тож останнє слово — за сертифікатом, про який звітує CryptoAPI. Кожен сирий вхід потрапляє в TPadesSignatureAlgorithmInfo, зокрема DigestParametersPresent, DigestParameterBits, SignatureParametersPresent і PublicKeyParametersAreNamedCurve, тож відхилення завжди можна пояснити з запису, а не з рядка лога

Чому підпис P-256 із SHA3-256 провалює політику?

Підпис P-256 провалює політику PDFium Component щоразу, коли digestAlgorithm CMS і дайджест, що мається на увазі за ECDSA signatureAlgorithm, розходяться, навіть якщо обидва поодинці прийнятні для кривої. EvaluatePadesSignatureAlgorithm спершу відображає ecdsa-with-SHA256, ecdsa-with-SHA3-256 і їхніх побратимів на дайджест, порівнює це із заявленим digestAlgorithm і повертає pcsInvalid за будь-якої різниці до того, як буде консультована таблиця кривих. Випадок реальний: інструмент підписування перемикає свій хеш на SHA3-256, але тримає зашитий ідентифікатор ecdsa-with-SHA256, і результат — файл, який жоден відповідний верифікатор не може тлумачити послідовно. Функція публічна, тож матрицю можна пришпилювати в unit-тесті, не будуючи PDF:

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); // розбіжність дайджестів

  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, тепер конгруентний
  Assert(EvaluatePadesSignatureAlgorithm(Info) = pcsUnsupported); // крива не в профілі
end;

Кодування кривої отримує ту саму суворість. RFC 5480 §2.1.1 дозволяє ECParameters бути OID іменної кривої, неявною кривою (NULL) чи повним явним набором параметрів, а профілі PKIX вимагають іменної форми. PDFium Component повертає pcsInvalid, коли сертифікат id-ecPublicKey несе відсутність параметрів, неявні параметри чи явні параметри, бо явні параметри дозволяють зловмиснику описати криву, яка лише схожа на стандартну. Правильно іменована крива, яку просто немає в списку ISO/TS 32002, як brainpoolP160r1 вище чи secp256k1, отримує натомість pcsUnsupported

Invalid, unsupported чи indeterminate: читайте статус чесно

Три невалідні статуси AlgorithmPolicyStatus означають різні речі, і злиття їх в одну шарабан «failed» викидає інформацію, потрібну аудиторам. pcsInvalid означає, що розпізнана комбінація алгоритмів помилкова чи розбіжна; він додає ppeiSignatureAlgorithmMismatch до TPadesValidationResult.Issues і жене агрегатний IntegrityStatus у pcsInvalid, тож IsCryptographicallyValid повертає False, навіть коли значення підпису CMS сходиться. pcsUnsupported означає, що крива чи дайджест поза тим, що називає профіль, — це результат можливостей, а не доказ підробки. pcsIndeterminate означає, що сертифікат підписувача не вдалося пришпилити, зазвичай це CertificateSet із кількома кандидатами і без точного збігу issuerAndSerialNumber, тож код відмовляється вгадувати криву; від v3.124.0 він також мітує підпис RSA поверх SHA-1 чи 112-бітового дайджесту на кшталт SHA-224, які більше не узгоджені для поточної валідації. Той самий поділ стосується EdDSA на машині, чия CryptoAPI не вміє верифікувати Ed25519 чи Ed448: CmsSignatureStatus лишається pcsUnsupported, поки AlgorithmPolicyStatus може бути pcsValid, бо кодування було коректним, а не вистачало лише верифікатора. Якщо ви женетеся за відхиленням від Adobe чи валідатора на DSS, гайд про те, чому валідатори відхиляють підписи PAdES покриває інші поширені причини

Шлях рішень EvaluatePadesSignatureAlgorithm у PDFium Component: розбіжність дайджестів ставить pcsInvalid і ppeiSignatureAlgorithmMismatch, іменована крива поза профілем ISO TS 32002, як brainpoolP160r1, ставить pcsUnsupported, сертифікат підписувача, який не вдається пришпилити, ставить pcsIndeterminate, а від v3.124.0 гілка RSA застосовує набори дайджестів ETSI TS 119 312, з розміром ключа, який досі лишено застосунку
Три невалідні статуси означають різні речі: invalid — доказ зламаної комбінації, unsupported — результат можливостей, а indeterminate означає, що код відмовився вгадувати
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; // офлайн, без перевірки відкликання
    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;

Чого pcsValid не гарантує?

AlgorithmPolicyStatus = pcsValid сертифікує лише те, що підпис ECDSA чи EdDSA використовує схвалену криву з конгруентним, правильно закодованим дайджестом, а підпис RSA — дайджест із поточного набору; про те, чи значення підпису коректне, він не каже нічого. До v3.124.0 гілка RSA в EvaluatePadesSignatureAlgorithm була навмисно широкою: будь-який signatureAlgorithm під дугою PKCS #1 1.2.840.113549.1.1.* повертав pcsValid, включно з застарілим sha1WithRSAEncryption. Від PDFiumPas v3.124.0 гілка RSA застосовує набори підписів ETSI TS 119 312. Дайджести MD2, MD4 і MD5 — pcsInvalid. SHA-1 і 112-бітові дайджести на кшталт SHA-224 — pcsIndeterminate, тож підпис SHA-1 зберігає свій загальний результат цілісності і мітиться на перегляд, а не відхиляється. digestAlgorithm, що відрізняється від дайджесту, зафіксованого алгоритмом підпису, як sha256WithRSAEncryption поверх дайджесту SHA-1, або алгоритм підпису RSA на ключі підписувача не-RSA, — це pcsInvalid, зі звітом ppeiSignatureAlgorithmMismatch і провалом цілісності. Сертифікат підписувача, який не вдається знайти, дає pcsIndeterminate, як уже було для ECDSA, а нерозпізнані дайджести чи RSA OID не-підписового призначення дають pcsUnsupported. Довжина модуля досі не перевіряється, PSS-параметри тут не валідуються (стаття про RFC 4055 RSASSA-PSS-params покриває, як вони кодуються на боці підписування), а дайджести SHA-1 чи MD5 додатково підіймають окрему проблему ppeiBadDigestAlgorithm. Так само математика підпису, ланцюжок сертифікатів і відкликання лишаються роботою CmsSignatureStatus, CertificateTrustStatus і RevocationStatus, які приходять із Windows CryptoAPI. Трактовте pcsValid як «профіль алгоритмів тримається», ніколи як «цей ключ достатньо міцний»

Для Delphi-застосунку, що приймає підписані PDF-рахунки, контракти чи архівні пакети, практичне налаштування коротке: ганяйте ValidatePadesTrust, відхиляйте на ppeiSignatureAlgorithmMismatch, маршрутизуйте pcsUnsupported і pcsIndeterminate до людини і забезпечуйте власну нижню межу розміру ключа RSA, бо політика цього не зробить. PDFium Component для Delphi і Lazarus везе валідатор PAdES, білдер звіту про докази і конвеєр підписування, тож та сама бібліотека може і видавати ці підписи, і перевіряти їх від кінця до кінця