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

Проверка ECDSA-подписи PDF в Delphi: DER в P1363

signatureValue ECDSA внутри контейнера CMS — это DER SEQUENCE { INTEGER r, INTEGER s }. Функция Windows CNG BCryptVerifySignature не принимает ни то ни другое: ей нужен IEEE P1363 фиксированной ширины r || s, без тегов и без длин. HotPDF, нативный VCL-компонент PDF для Delphi и C++Builder, конвертирует между ними по строгим правилам DER перед импортом ключа

Сбой, который это предотвращает, — специфичный и деморализующий. Acrobat открывает документ и показывает зелёную галочку. Ваш собственный верификатор, обходя те же байты, возвращает недействительно, или CNG отдаёт STATUS_INVALID_SIGNATURE без дальнейших объяснений. С подписью всё в порядке. Неверно то, что примерно семьдесят байт ASN.1 были переданы API, ожидавшему шестьдесят четыре байта сырого целого, и это несоответствие невидимо, если не знать, где искать

Почему BCryptVerifySignature отвергает действительную ECDSA-подпись?

Потому что две стороны вызова говорят на разных кодировках подписи, и ни одна из них об этом не объявляет. ISO 32000-1 §12.8 говорит, что словарь подписи несёт блоб CMS в /Contents; RFC 5652 §5.3 говорит, что signatureValue в каждом SignerInfo — это OCTET STRING, чьё содержимое определяется алгоритмом подписи. Для ECDSA этим содержимым является структура DER SEC 1: SEQUENCE, держащий два INTEGER. Это переменная длина по замыслу, потому что r и s — целые числа, а DER отрезает ведущие нулевые октеты у целых

IEEE P1363 придерживается противоположного взгляда. Он определяет подпись как конкатенацию двух координат, каждая дополнена слева нулями ровно до байтовой ширины поля кривой. Подпись P-256 всегда 64 байта. DER-кодировка той же подписи обычно 70 или 71 байт и может быть где-то от примерно 8 до 72. Передайте форму DER в BCryptVerifySignature, и одна лишь проверка длины обрекает вызов, поэтому HotPDF нормализует перед проверкой, а не после

uses
  HPDFECDSA;

// Converts a CMS signatureValue into the fixed-width form CNG expects.
// ARaw comes back as 64 bytes for P-256, 96 for P-384, 132 for P-521.
function ToP1363(const ADerSig: TBytes; ACurve: THPDFECDSACurve;
  out ARaw: TBytes): Boolean;
begin
  Result := HPDFECDSANormalizeSignature(ADerSig, ACurve, eseDER, ARaw);
end;

Правила DER, которые парсер подписи не должен ослаблять

Каждый отказ, перечисленный здесь, — это отказ, который HotPDF выполняет намеренно, и каждый закрывает путь, который снисходительный парсер оставил бы открытым. Соблазн при написании конвертера — найти два узла INTEGER, скопировать их содержимое и двигаться дальше. Это работает на корректно сформированном входе и молча принимает семейство податливых переформулировок на враждебном входе. Поэтому HPDFECDSANormalizeSignature отвергает отрицательное целое, то есть любое r или s, чей первый октет содержимого имеет установленный старший бит, потому что действительный скаляр ECDSA положителен. Он отвергает значение, целиком равное нулю, поскольку r = 0 или s = 0 никогда не является законной подписью. Он отвергает избыточный ведущий нулевой октет: X.690 §8.3 разрешает ровно один, и только когда следующий октет иначе читался бы как отрицательный, так что 00, за которым следует октет ниже 0x80, — это переформулировка, а не подпись. Он отвергает неминимальный заголовок длины, потому что X.690 §10.1 требует определённой формы, закодированной наименьшим числом октетов, а длинная форма длины, которая могла бы быть короткой формой, — это другая байтовая строка, несущая тот же смысл. Он отвергает целое шире размера координаты кривой, поскольку такое значение не может быть элементом поля. И он отвергает любой хвостовой узел после s, а также внешний SEQUENCE, чья полная длина не равна длине всего блоба

Эти последние два важны сильнее, чем кажутся. Хвостовые байты после SEQUENCE — классический приём податливости подписи: добавьте мусор, и снисходительный верификатор всё равно скажет действительно, тогда как байтовая строка, которую он проверил, — не та байтовая строка, что была подписана. Тот же инстинкт движет ужесточением длины ASN.1, описанным в заметке о парсинге PKCS#12, и здесь тот же инстинкт. В пути проверки принятая структура, которую соответствующий подписант никогда не выпускал, — это дефект, а не любезность

Ширина координаты принадлежит кривой, а не подписи

HotPDF выводит выходную ширину из именованного OID кривой, никогда — из длины только что распарсенного DER. Это вторая половина конвертации, и та половина, где легко тонко ошибиться. RFC 5480 §2.1.1 идентифицирует кривую в параметрах SubjectPublicKeyInfo сертификата, и HPDFECDSACurveFromOID отображает три OID, которые поддерживает HotPDF: 1.2.840.10045.3.1.7 для P-256, 1.3.132.0.34 для P-384 и 1.3.132.0.35 для P-521. HPDFECDSACoordinateSize затем возвращает 32, 48 или 66 байт, а буфер P1363 — вдвое больше: 64, 96 или 132. Каждое декодированное целое выравнивается по правому краю в свою половину, так что короткое r дополняется нулями слева, а не сдвигается. P-521 — тот, что ловит людей, потому что 521 бит — это 65,125 байта, округляющиеся до 66, что даёт подпись в 132 байта, которую никакая интуиция степени двойки не предсказала бы. Открытый ключ путешествует рядом как несжатая точка EC согласно RFC 5480 §2.2, то есть 0x04, за которым следуют X и Y, так что HotPDF проверяет, что он ровно 1 + 2 * CoordinateSize байт и начинается с 0x04, прежде чем прикоснуться к CNG

var
  Digest, SigDER, PublicPoint: TBytes;
  Curve: THPDFECDSACurve;
  Res: THPDFECDSAVerifyResult;
begin
  // secp256r1, taken from the certificate SubjectPublicKeyInfo parameters
  Curve := HPDFECDSACurveFromOID('1.2.840.10045.3.1.7');

  // PublicPoint must be $04 || X || Y, so 1 + 2 * 32 = 65 bytes for P-256
  Res := HPDFECDSAVerifyDigest(Digest, SigDER, PublicPoint, Curve, eseDER);

  case Res of
    evrValid:
      Memo1.Lines.Add('signature verifies');
    evrInvalid:
      Memo1.Lines.Add('signature does not match the digest');
    evrMalformed:
      Memo1.Lines.Add('DER encoding or public point rejected');
    evrUnsupported:
      Memo1.Lines.Add('curve or algorithm not supported here');
    evrProviderUnavailable:
      Memo1.Lines.Add('bcrypt.dll or the curve provider is missing');
    evrProviderError:
      Memo1.Lines.Add('CNG returned an unexpected status');
  end;
end;

Обратите внимание на последний параметр. HPDFECDSAVerifyDigest также принимает eseP1363 для вызывающих сторон, уже держащих подпись фиксированной ширины, — от аппаратного токена или удалённого сервиса подписания, возвращающего сырые r || s. Этот путь по-прежнему обеспечивает проверку длины и ненулевую проверку обеих половин, так что буфер верного размера, полный нулей, отклоняется, а не пропускается провайдеру

Почему общее имя алгоритма ECDSA не работает на старом Windows?

Потому что общее имя новее, чем базовое развёртывание, для которого вы делаете сборку. CNG выставляет идентификатор алгоритма ECDSA, который выводит кривую из импортированного ключа, и это чистый способ написать этот код, но BCryptOpenAlgorithmProvider гарантированно разрешает его только на более новых версиях Windows. На старой машине вызов открытия проваливается, дескриптор провайдера остаётся nil, и каждая проверка ECDSA в вашем приложении сообщает «не поддерживается» на подписи, которая совершенно корректна. HotPDF избегает этого обрыва, открывая идентификаторы по кривым вместо этого. Он разрешает ECDSA_P256, ECDSA_P384 и ECDSA_P521 единожды, кэширует один дескриптор провайдера на кривую и закрывает их в финализации модуля. Каждая проверка затем делает только дешёвую работу: импортировать временный открытый ключ из ECCPUBLICBLOB, вызвать BCryptVerifySignature, уничтожить ключ. Никаких повторных LoadLibrary, никаких повторных GetProcAddress, никакого открытия и закрытия провайдера на каждую подпись. Пакетная проверка нескольких сотен документов ощущает разницу, как и сервисный процесс, который иначе перемалывал бы дескрипторы провайдера под нагрузкой

Коды результата остаются честными в этом различии. evrProviderUnavailable означает, что машина не смогла дать HotPDF провайдера; evrInvalid означает, что CNG ответил STATUS_INVALID_SIGNATURE. Смешение этих двух в один отказ — это то, как проблема развёртывания превращается в ложное сообщение о подделанном документе. То же разделение между сбоем окружения и криптографическим сбоем проходит через обработку CNG и CAPI на стороне подписания, описанную в статье о подписании через хранилище сертификатов и порядке байт

Какой сертификат это подписал? SignerIdentifier — это две разные вещи

RFC 5652 §5.3 делает SignerIdentifier типом CHOICE, и верификатор, обрабатывающий только одну ветвь, молча проверит по неверному ключу. Первая ветвь — issuerAndSerialNumber, SEQUENCE, держащий Name эмитента в сыром DER и INTEGER серийного номера, и сопоставление его — это побайтовое сравнение с каждым сертификатом в наборе certificates CMS. Вторая ветвь — [0] subjectKeyIdentifier, неявно тегированный OCTET STRING, и сопоставление его требует копания в сертификате, а не сравнения полей его заголовка

Это копание имеет слой, который удивляет людей. Идентификатор ключа живёт в расширении X.509v3, так что HotPDF обходит поле расширений [3] tbsCertificate, находит расширение, чей OID — 2.5.29.14, пропускает опциональный критический BOOLEAN и берёт OCTET STRING extnValue. Эта октетная строка не является идентификатором. Согласно RFC 5280 §4.2.1.2, её содержимое само является DER, а тип KeyIdentifier — это ещё один OCTET STRING, так что вы парсите второй раз, чтобы добраться до реальных байт. Остановитесь на слой раньше — и вы сравните 22-байтовую обёртку с 20-байтовым идентификатором, ни один сертификат не совпадёт, а верификатор откатится на какую-нибудь эвристику, которую вы написали дальше, — вот это и есть реальная опасность. Взять первый сертификат в наборе — соблазнительный ярлык, и он неверен всякий раз, когда CMS несёт цепочку, что бывает большую часть времени, потому что лист не обязан идти первым. HotPDF принимает несопоставленный сертификат только когда контейнер держит ровно один; при наличии нескольких сертификатов точное совпадение SignerIdentifier обязательно. Проверка дайджеста против открытого ключа промежуточного CA не выдаёт дружелюбную ошибку — она выдаёт уверенное «недействительно» на документе, который в порядке

var
  Pdf: THotPDF;
  Info: THPDFSignatureInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('signed.pdf') > 0 then
      for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
        if Pdf.VerifyLoadedSignatureEx(I, Info) = svValid then
          Memo1.Lines.Add(Format('%s: %s %s over %s, signer %s',
            [String(Info.FieldName), String(Info.PublicKeyAlgorithm),
             String(Info.CurveName), String(Info.HashAlgorithm),
             String(Info.SignerName)]))
        else
          Memo1.Lines.Add(Format('%s: not valid', [String(Info.FieldName)]));
  finally
    Pdf.Free;
  end;
end;

THPDFSignatureInfo.CurveName сообщает P-256, P-384 или P-521, так что журнал аудита записывает, какая кривая была реально использована, а не просто слово ECDSA. Документная механика вокруг этого вызова, в частности как хешируются сегменты /ByteRange и почему дайджест должен вычисляться по файлу, а не по разобранному дереву объектов, — тема сопутствующей статьи о проверке цифровых подписей PDF

Чего это вам не даёт

Зелёный результат от HPDFECDSAVerifyDigest отвечает лишь на один вопрос: эти байты были подписаны закрытым ключом, соответствующим этому открытому ключу. Он ничего не говорит о том, принадлежит ли этот ключ кому-то, кому вы должны доверять. Построение цепочки к якорю доверия, отзыв через CRL или OCSP и проверки политики — отдельная работа, и любой продукт, сообщающий о действительной подписи без них, сообщает меньше, чем предполагает пользователь. Даты действительности сертификата выставляются отдельно в THPDFSignatureInfo именно по этой причине: подпись может криптографически проверяться, пока сертификат, её создавший, истёк два года назад. Поддержка кривых также намеренно узка. Обрабатываются три простые кривые NIST, а подпись над любой другой кривой возвращает «не поддерживается», а не догадку. Путь CNG работает только на Windows, что является верным компромиссом для VCL-компонента, но об этом стоит упомянуть, прежде чем планировать вокруг него кроссплатформенный сервис. И строгость не настраивается: нет снисходительного режима, принимающего неминимальную длину DER только потому, что какой-то устаревший подписант её выпустил. Если вы встретите такой файл в продакшне, честный ответ — зафиксировать его и разбираться с производителем, а не расширять парсер, пока файл не пройдёт

Путь проверки ECDSA, описанный здесь, поставляется как часть стандартного компонента HotPDF для Delphi и C++Builder, наряду с путями RSA PKCS#1 v1.5 и RSA-PSS и полной записью информации о подписи; страница продукта содержит полный справочник по цифровой подписи