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) | sha1 | SHA-256 | Да |
maskGenAlgorithm | [1] (A1) | mgf1SHA1 | MGF1 с SHA-256 | Да |
saltLength | [2] (A2) | 20 | 32 (длина дайджеста) | Да |
trailerField | [3] (A3) | trailerFieldBC (1) | 1 | Нет, равно умолчанию |
Теги нужны потому, что каждое поле необязательно через своё умолчание. hashAlgorithm и maskGenAlgorithm — оба SEQUENCE типа AlgorithmIdentifier; без тега декодер, глядящий на блок параметров с единственной SEQUENCE, не отличил бы, которая из двух. С тегами номер тега называет поле, каких бы соседей кодировщик ни оставил за бортом
Возьмите соль, равную длине дайджеста, и не меняйте её. Некоторые 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
Корректная версия — три вызова 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
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 можно прочитать и сдампить рядом с выводом собственного подписанта, байт в байт