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

Кодирование RSASSA-PSS-params по RFC 4055 в PDFium Delphi

Hex-дамп signatureAlgorithm RSASSA-PSS, кончающийся A0 0F … A1 1C … 02 01 20 02 01 01, выглядит законченным: SHA-256, MGF1, 32-байтовая соль и трейлер 1. Строгий декодер отвергает такой блок параметров целиком, а снисходительный читает длину соли как 20. В обоих случаях подпись с правильной RSA-арифметикой возвращается невалидной. Почему — объясняет RFC 4055 §3.1: каждое поле RSASSA-PSS-params несёт явный контекстный тег, поэтому соль должна лежать внутри A2, а по DER trailerField, равный своему умолчанию, не должен появляться вовсе

Отсюда у подписи CMS две половины, способные отказать независимо. Одна — криптографическая операция; другая — ASN.1, который сообщает проверяющему, как операция выполнялась. Сделайте первую верно, а вторую неверно — и проверяющий, следующий спецификации, не отличит ваш документ от подделки. Про вторую половину и эта статья

Почему проверяющий отвергает подпись RSASSA-PSS с верными байтами?

Потому что RSASSA-PSS — единственная схема RSA, где проверяющий не может вывести параметры из OID алгоритма. PKCS#1 v1.5 полностью определён самим sha256WithRSAEncryption, поэтому его параметры — голый NULL. PSS параметризуется хешем сообщения, функцией генерации маски со своим хешем и длиной соли, и RFC 8017 §A.2.3 оставляет все три на усмотрение подписанта. AlgorithmIdentifier несёт выбор подписанта, и проверяющий обязан воспроизвести его в точности, прежде чем запустится EMSA-PSS-VERIFY

Блок, который проверяющий не может разобрать, обрывает проверку до всякого модульного возведения в степень. Блок, который он разбирает иначе, хуже: RFC 4055 §3.1 даёт saltLength умолчание 20, поэтому декодер, так и не нашедший поле соли, гоняет EMSA-PSS-VERIFY с 20-байтовой солью против подписи, вычисленной с 32. Результат рапортуется как плохая подпись — без единого указания на метаданные. То, насколько строг проверяющий, решает, какой из двух отказов вы увидите, и ни одно сообщение об ошибке не упоминает AlgorithmIdentifier

Четыре поля RSASSA-PSS-params и их теги

RFC 4055 §3.1 определяет RSASSA-PSS-params как SEQUENCE из четырёх полей, каждое с явным контекстным тегом и значением DEFAULT. В DER явное тегирование заворачивает каждое поле в constructed TLV контекста — от A0 до A3 — с вложенным внутрь универсальным кодированием самого поля:

ПолеТег (байт DER)DEFAULTЗначение ETSI TS 119 312 §7Кодируется?
hashAlgorithm[0] (A0)sha1SHA-256Да
maskGenAlgorithm[1] (A1)mgf1SHA1MGF1 с SHA-256Да
saltLength[2] (A2)2032 (длина дайджеста)Да
trailerField[3] (A3)trailerFieldBC (1)1Нет, равно умолчанию

Теги нужны потому, что каждое поле необязательно через своё умолчание. hashAlgorithm и maskGenAlgorithm — оба SEQUENCE типа AlgorithmIdentifier; без тега декодер, глядящий на блок параметров с единственной SEQUENCE, не отличил бы, которая из двух. С тегами номер тега называет поле, каких бы соседей кодировщик ни оставил за бортом

Схема PDFium Component с RSASSA-PSS-params по RFC 4055: hashAlgorithm A0, maskGenAlgorithm A1 и saltLength A2 несут явные теги контекста со значениями DEFAULT, профиль ETSI выдаёт SHA-256, MGF1 с SHA-256 и соль 32, а trailerField A3 равен trailerFieldBC, поэтому DER опускает его целиком
Каждое поле помечено именно потому, что каждое необязательно через своё умолчание, поэтому номер тега опознаёт поле, каких бы соседей кодировщик ни оставил за бортом

Возьмите соль, равную длине дайджеста, и не меняйте её. Некоторые API предлагают «максимальную соль» или «авто-длину соли», что даёт значение, зависящее от размера ключа; по RFC 8017 это законно, но тогда AlgorithmIdentifier обязан называть число, меняющееся от ключа к ключу, а ETSI TS 119 312 §7 и так просит 32-байтовую соль для SHA-256. Встроенные бэкенды подписи PDFium Component все фиксируют её на 32 и передают платформенному вызову то же число: cbSalt в BCRYPT_PSS_PADDING_INFO, уходящем в NCryptSignHash на Windows, sLen в CK_RSA_PKCS_PSS_PARAMS для токена PKCS#11, EVP_PKEY_CTX_set_rsa_pss_saltlen под OpenSSL и соль длины дайджеста, которую подразумевает kSecKeyAlgorithmRSASignatureDigestPSSSHA256 на macOS

Как написать GetSignatureAlgorithmParams для собственного ICmsSigner

В PDFium Component каждый бэкенд подписи реализует интерфейс ICmsSigner из FPdfCms.pas, и блок параметров приходит из одного метода — GetSignatureAlgorithmParams. Сборщик CMS сочетает его результат с OID из GetSignatureAlgorithmOid через TDerWriter.AlgIdWithParams. С этим методом вы сталкиваетесь, подключая собственного подписанта через ConfigurePadesSignerProvider — сетевой HSM, сервис подписи, драйвер смарт-карты, которого встроенные бэкенды не покрывают. Кодирование, которое чаще всего пишут первым делом, тегирует оба AlgorithmIdentifier — их очевидно надо завернуть — и оставляет оба целых числа голыми:

// Частая первая попытка: [0] и [1] помечены, [2] и [3] нет
Result := W.Sequence(Concat4(
  W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
  W.ContextSpecific(1, W.Sequence(ConcatBytes(
    W.OID('1.2.840.113549.1.1.8'),
    W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
  W.IntegerOf(32),      // голый INTEGER там, где требуется [2] EXPLICIT
  W.IntegerOf(1)));     // trailerField, равный DEFAULT: должен отсутствовать

Пройдите по нему, как это делает декодер. Он видит A0 и читает хеш, видит A1 и читает функцию генерации маски, затем встречает 02 01 20 — универсальный INTEGER. У RSASSA-PSS-params нигде нет непомеченного члена INTEGER, поэтому строгий декодер останавливается. Снисходительный пропускает неизвестный элемент, так и не находит A2, назначает соль по умолчанию 20, а потом спотыкается о второй лишний INTEGER, 02 01 01. Ни один путь не заканчивается на 32

Схема ловушки тегирования RSASSA-PSS-params: после A0 и A1 неверно помеченный блок встречает голый 02 01 20 там, где положен явный тег A2; строгий декодер останавливается, снисходительный откатывается к умолчанию в 20 байт, а корректное кодирование заворачивает 32-байтовую соль в A2
У RSASSA-PSS-params нет непомеченного члена INTEGER, поэтому лишние байты здесь — это либо отказ разбора, либо молчаливый откат к умолчанию длины соли, и ни один путь не доходит до 32 у подписанта

Корректная версия — три вызова TDerWriter.ContextSpecific из FPdfAsn1.pas, по одному на каждое не-умолчальное поле, каждый с Constructed равным True, чтобы результат был явной обёрткой. Строки для поля трейлера нет. Это тот же блок, что выдают бэкенды Windows CNG, macOS Keychain, PKCS#11 и OpenSSL, выписанный здесь так, как его нес бы собственный подписант:

uses
  System.SysUtils, FPdfSha256, FPdfAsn1, FPdfCms;

type
  TMyHsmSigner = class(TInterfacedObject, ICmsSigner)
  private
    FUsePss: Boolean;
  public
    function GetDigestAlgorithmOid: AnsiString;
    function GetSignatureAlgorithmOid: AnsiString;
    function GetSignatureAlgorithmParams: TBytes;
    function GetSignerId: TCmsSignerId;
    function SignSignedAttrsDigest(const Digest: TSHA256Digest): TBytes;
  end;

// Ваш собственный класс; остальные четыре метода говорят с вашим HSM и не показаны
function TMyHsmSigner.GetSignatureAlgorithmParams: TBytes;
var
  W: TDerWriter;
begin
  if not FUsePss then
    Exit(nil);   // v1.5 и ECDSA: AlgIdWithParams пишет NULL для nil
  W := TDerWriter.Create;
  try
    // RFC 4055 3.1: [0] хеш, [1] MGF1 со своим хешем, [2] соль.
    // [3] trailerField = 1 равен своему DEFAULT, поэтому X.690 11.5 его опускает
    Result := W.Sequence(Concat3(
      W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
      W.ContextSpecific(1, W.Sequence(ConcatBytes(
        W.OID('1.2.840.113549.1.1.8'),             // id-mgf1
        W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
      W.ContextSpecific(2, W.IntegerOf(32), True)));
  finally
    W.Free;
  end;
end;

TDerWriter.AlgId пишет AlgorithmIdentifier с параметрами NULL — ровно то, что RFC 4055 §2.1 велит генерировать кодировщикам для вложенных идентификаторов SHA-256. Получающиеся байты коротки, их можно проверить глазами: внешняя 30 34, затем A0 0F вокруг 15-байтового AlgorithmIdentifier SHA-256, A1 1C вокруг 28-байтового AlgorithmIdentifier MGF1 и A2 03 02 01 20 для соли. Соль, которую вы пишете в GetSignatureAlgorithmParams, обязана совпадать с той, что реально использует ваш HSM; если сервис выставляет наружу лишь «PSS с SHA-256» и не говорит длину соли, выясните её до релиза — AlgorithmIdentifier есть утверждение о ней

Почему trailerField опускается, а не помечается как [3]?

X.690 §11.5 говорит, что кодировщик DER не должен кодировать компонент, чьё значение равно его DEFAULT. У trailerField DEFAULT — trailerFieldBC, целое 1, а PSS в определении RFC 8017 использует только этот трейлер. Поэтому очевидная правка первой попытки — замена W.IntegerOf(1) на W.ContextSpecific(3, W.IntegerOf(1), True) — даёт A3 03 02 01 01, что снисходительный декодер BER примет, а строгий декодер DER вправе отвергнуть. Значение верно. Его присутствие — нет

Тем же правилом объясняется, почему остальные три поля присутствуют: SHA-256 — не sha1, MGF1 с SHA-256 — не mgf1SHA1, а 32 — не 20. Подписант с SHA-1 и 20-байтовой солью свернул бы весь блок в пустую SEQUENCE, 30 00. Эта форма сбивает всякого, кто уверен, что «без параметров» всегда пишется как 05 00: для id-RSASSA-PSS в CMS поле параметров обязательно, пустая SEQUENCE означает «все умолчания», а NULL не есть допустимое значение RSASSA-PSS-params

Схема PDFium Component с правилом X.690 11.5 в параметрах PSS: trailerField, равный своему DEFAULT trailerFieldBC, должен остаться отсутствующим, потому что тегированная обёртка A3 — то кодирование, которое строгий DER отвергает, а saltLength 32 отличается от умолчания 20 и обязан присутствовать как A2
DER существует ради того, чтобы у одного значения было ровно одно кодирование, а компонент, равный своему умолчанию, уже носит кратчайшее из возможных кодирований — а именно не появляться вовсе

BER разрешает кодировщику включать компонент со значением по умолчанию; DER запрещает — он существует ради того, чтобы у одного значения было ровно одно кодирование, а подпись над структурой с двумя законными кодированиями можно оспаривать. Уйти от DER блок параметров здесь не может: он лежит во внешнем signatureAlgorithm SignerInfo и копируется в подписанные атрибуты через атрибут cmsAlgorithmProtection из RFC 6211, где подписью покрыт каждый байт

Проверка CMS, который вы не строили

Копия внутри cmsAlgorithmProtection ловушки с неверным тегированием не ловит: атрибут и внешний signatureAlgorithm выходят из одного вызова, так что совпадают всегда, и сравнение структуры с самой собой проходит. Ловит проблему декодер, читающий параметры поле за полем. InspectPadesSignatureAlgorithm в FPdfPades.pas у PDFium Component делает именно это: принимает только A0–A3, каждый не более одного раза и по возрастанию, подставляет умолчания RFC 4055 для всего отсутствующего и сообщает эффективные значения в TPadesRsaPssParameters. Голый INTEGER в блоке оставляет SignaturePss.Valid равным False, и тогда EvaluatePadesSignatureAlgorithm ставит PolicyStatus в pcsInvalid. Он также отвергает PSS-хеш, отличающийся от алгоритма дайджеста SignerInfo, — ровно то, что дало бы снисходительное откатывание к sha1

Важнее всего это при удалённой подписи, где внешний сервис возвращает готовый CMS, а CompletePadesRemoteSignature встраивает его в подготовленный PDF. Проверяйте блоб до встраивания: отказ на этом этапе стоит одного вызова API, а выяснение от валидатора заказчика стоит раунда переподписи с теми, кто сервис обслуживает:

uses
  System.SysUtils, FPdfPades;

// Рекомендуемая проверка, которую вы добавляете сами перед CompletePadesRemoteSignature
function CheckRemotePssCms(const CmsDer: TBytes; out Why: string): Boolean;
const
  OID_RSASSA_PSS = '1.2.840.113549.1.1.10';
var
  Info: TPadesSignatureAlgorithmInfo;
  Values: string;
begin
  Result := False;
  Why := '';
  // nil: пусть инспектор сам найдёт сертификат подписанта внутри CMS
  if not InspectPadesSignatureAlgorithm(CmsDer, nil, Info) then
  begin
    Why := 'CMS SignerInfo could not be parsed';
    Exit;
  end;
  if Info.SignatureAlgorithmOid = OID_RSASSA_PSS then
  begin
    if not Info.SignaturePss.Valid then
    begin
      Why := 'RSASSA-PSS-params does not decode as RFC 4055 3.1';
      Exit;
    end;
    // Эффективные значения, включая умолчания: отсутствующая A2 видна здесь как 20
    Why := Format('hash %s, MGF hash %s, salt %d', [
      string(Info.SignaturePss.HashAlgorithmOid),
      string(Info.SignaturePss.MaskGenHashAlgorithmOid),
      Info.SignaturePss.SaltLength]);
  end;
  if Info.PolicyStatus = pcsInvalid then
    Exit;   // Why всё ещё держит декодированные значения для лога
  Values := Why;
  Result := VerifyCmsAlgorithmProtection(CmsDer, Why);
  if Result then
    Why := Values;   // проверка защиты стирает Why при успехе
end;

Логируйте строку Why даже при успехе. Длина соли 20 рядом с хешем SHA-256 — отпечаток блока параметров, потерявшего свой A2, что бы ни говорили собственные логи сервиса. Для быстрого взгляда без кода есть openssl asn1parse -inform DER -in signature.p7s, печатающий дерево: под OID id-RSASSA-PSS вы должны увидеть cont [ 2 ], обёртывающий INTEGER со значением 20 в hex, и никаких cont [ 3 ] вовсе. INTEGER верхнего уровня внутри той SEQUENCE — это неверно тегированное кодирование из первого кодового блока

Куда это встраивается в остальную историю PSS

Кодирование параметров — один из трёх независимых способов, которыми PSS ломается в подписи PAdES, и разделять их — значит сокращать отладку. На macOS конкретный ключ или более старая система могут не предлагать PSS, и тогда бэкенд подписывает через PKCS#1 v1.5, объявляя sha256WithRSAEncryption в соответствии; этот вопрос возможностей разобран в материале о подписи PAdES с идентификатором macOS Keychain. Токен PKCS#11 может читать CK_RSA_PKCS_PSS_PARAMS с иной шириной целого, чем писал вызывающий, — вопрос ABI, разобранный в материале о CK_ULONG и ловушке упаковки PKCS#11. Третий — тот, что выше: ключ согласен, токен вычисляет правильные байты, а DER, их описывающий, не следует RFC 4055 §3.1

У SET подписанных атрибутов есть своя версия слепого пятна самосогласованности: SET, хешированный в одном порядке и выданный в другом, выглядит нормально, пока чужой проверяющий не пересчитает хеш; см. CMS signedAttrs и сортировку DER SET OF. Для цикла «подготовить — подписать удалённо — завершить», куда встраивается проверка выше, удалённая подпись PAdES с HSM разбирает двухфазный протокол и то, что отказывает шаг завершения

PDFium Delphi component поставляет бэкенды подписи исходником, так что тела GetSignatureAlgorithmParams в юнитах Windows, macOS, PKCS#11 и OpenSSL можно прочитать и сдампить рядом с выводом собственного подписанта, байт в байт