Техническая статья

Проверки 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, каждая с парным digest, а несовпадение поднимает ppeiSignatureAlgorithmMismatch. Эта политика важнее, чем звучит. Подпись на brainpoolP160r1 или ключ P-256, подписывающий digest SHA-512, прекрасно верифицируются на уровне математики, поэтому Windows CryptoAPI рапортует значение подписи как хорошее, в то время как строгий валидатор PDF 2.0 отвергает файл. Проверка политики закрывает ту брешь, и она нарочно отделена от вопроса, корректны ли байты подписи криптографически

Что ISO/TS 32002 на самом деле допускает для подписей на эллиптических кривых?

ISO/TS 32002 допускает в подписях PDF ровно шесть кривых ECDSA и две схемы EdDSA, и каждую кривую привязывает к ширинам digest, которые она может нести. PDFium Component кодирует ту таблицу в PadesCurveDigestAllowed, с ключом по OID кривой из сертификата подписанта. Кривые NIST строги: digest обязан иметь ту же битовую ширину, что и кривая, 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): любой digest 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 принимают только парные ширины digest в PadesCurveDigestAllowed, brainpoolP256r1 принимает 256–512 бит, brainpoolP384r1 — 384 и 512, Ed25519 и Ed448 объявляют SHA-512 и SHAKE256 с длиной 512, несовпадения поднимают ppeiSignatureAlgorithmMismatch, а кривые вне профиля возвращают pcsUnsupported
Пройти могут шесть кривых ECDSA и две схемы EdDSA, и каждая кривая привязана к ширинам digest, которые может нести; всё прочее — invalid или unsupported, но никогда не принимается молча

У EdDSA нет ни выбора кривой, ни выбора digest, и именно поэтому его правила — про кодирование, а не про стойкость. По RFC 8419 SignerInfo на Ed25519 обязан объявить SHA-512 своим digestAlgorithm без параметров, а SignerInfo на Ed448, на пути 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 ответить на этот вопрос не может: его публичный signature API читает словарь подписи, но ни CMS не верифицирует, ни кривую сертификата подписанта не выдаёт. Поэтому слой инспекции PAdES, построенный поверх PDFium, парсит CMS SignedData (RFC 5652) сам. InspectPadesSignatureAlgorithm читает digestAlgorithm и signatureAlgorithm первого SignerInfo, затем находит сертификат подписанта и читает его SubjectPublicKeyInfo, чтобы получить алгоритм ключа и кривую. Поиск сертификата нарочно ограничен: рассматривается максимум 64 сертификата из набора certificates CMS, матч — точное байтовое сравнение issuer и serial number из 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 всякий раз, когда CMS-поле digestAlgorithm и digest, подразумеваемый ECDSA-полем signatureAlgorithm, расходятся, даже если каждый по отдельности допустим для кривой. EvaluatePadesSignatureAlgorithm сперва мапит ecdsa-with-SHA256, ecdsa-with-SHA3-256 и их собратьев в digest, сверяет это с объявленным digestAlgorithm и возвращает pcsInvalid при любом расхождении ещё до обращения к таблице кривых. Случай настоящий: подписывающий инструмент переключает хеш на SHA3-256, но оставляет зашитый идентификатор ecdsa-with-SHA256, и результат — файл, который ни один соответствующий стандарту верификатор не сможет трактовать согласованно. Функция публичная, поэтому матрицу можно пришпилить юнит-тестом, не собирая 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); // digest не совпал

  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 означают разное, и слипание их в одно ведро «провалено» выбрасывает информацию, которая нужна аудиторам. pcsInvalid означает, что распознанная комбинация алгоритмов искажена или не согласована; он добавляет ppeiSignatureAlgorithmMismatch в TPadesValidationResult.Issues и гонит агрегатный IntegrityStatus в pcsInvalid, так что IsCryptographicallyValid возвращает False, даже когда значение CMS-подписи сходится. pcsUnsupported означает, что кривая или digest вне того, что называет профиль, — это результат о возможностях, а не доказательство подделки. pcsIndeterminate означает, что сертификат подписанта не удалось пришпилить, обычно CertificateSet с несколькими кандидатами и без точного матча issuerAndSerialNumber, поэтому код отказывается угадывать кривую; с v3.124.0 он помечает так же и RSA-подпись поверх SHA-1 или 112-битного digest вроде SHA-224, которые более не согласованы для актуальной валидации. Тот же расклад относится к EdDSA на машине, чья CryptoAPI не умеет верифицировать Ed25519 или Ed448: CmsSignatureStatus остаётся pcsUnsupported, тогда как AlgorithmPolicyStatus может быть pcsValid — кодирование было верным, не хватало лишь верификатора. Если вы гоняетесь за отказом от Adobe или валидатора на базе DSS, гид о том, почему валидаторы отвергают подписи PAdES, покрывает остальные частые причины

Путь решения EvaluatePadesSignatureAlgorithm в PDFium Component: несовпадение digest ставит pcsInvalid и ppeiSignatureAlgorithmMismatch, именованная кривая вне профиля ISO TS 32002 вроде brainpoolP160r1 ставит pcsUnsupported, не пришпилившийся сертификат подписанта ставит pcsIndeterminate, а с v3.124.0 ветка RSA применяет наборы digest 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; // офлайн, без revocation
    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 использует одобренную кривую с согласованным и корректно закодированным digest, а подпись RSA — digest из актуального набора; о том, верно ли значение подписи, она не говорит ничего. До v3.124.0 RSA-ветка EvaluatePadesSignatureAlgorithm была нарочно широкой: любой signatureAlgorithm под аркой PKCS #1 1.2.840.113549.1.1.* возвращал pcsValid, включая legacy-схему sha1WithRSAEncryption. С PDFiumPas v3.124.0 RSA-ветка применяет наборы подписей ETSI TS 119 312. Digest MD2, MD4 и MD5 — pcsInvalid. SHA-1 и 112-битные digest вроде SHA-224 — pcsIndeterminate, так что SHA-1-подпись сохраняет свой общий результат целостности и помечается на ревью, а не отвергается. digestAlgorithm, отличный от digest, фиксированного алгоритмом подписи, скажем sha256WithRSAEncryption поверх digest SHA-1, или алгоритм RSA-подписи на ключе подписанта не-RSA — это pcsInvalid, репортится как ppeiSignatureAlgorithmMismatch и проваливает целостность. Ненайденный сертификат подписанта даёт pcsIndeterminate, как и раньше для ECDSA, а нераспознанные digest или не-подписные RSA OID дают pcsUnsupported. Длина модуля по-прежнему не проверяется, параметры PSS здесь не валидируются (статья о RSASSA-PSS-params по RFC 4055 рассказывает, как они кодируются на подписывающей стороне), а digest SHA-1 или MD5 вдобавок поднимают отдельный вопрос ppeiBadDigestAlgorithm. Равным образом математика подписи, цепочка сертификатов и revocation остаются за CmsSignatureStatus, CertificateTrustStatus и RevocationStatus, приходящими из Windows CryptoAPI. Считайте pcsValid «профиль алгоритмов сходится» и никогда — «этот ключ достаточно стоек»

Для Delphi-приложения, принимающего подписанные PDF-счёта, контракты или архивные пакеты, практическая настройка коротка: гоняйте ValidatePadesTrust, отвергайте по ppeiSignatureAlgorithmMismatch, маршрутизируйте pcsUnsupported и pcsIndeterminate человеку и enforcing'ьте собственный RSA-пол по размеру ключа, потому что политика этого не сделает. PDFium Component для Delphi и Lazarus везёт с собой валидатор PAdES, сборщик отчёта-доказательства и подписывающий конвейер, так что та же библиотека может и производить такие подписи, и проверять их end to end