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

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

PDFium Component версии 3.114.20 исправляет кодирование RSASSA-PSS-params во всех трёх бэкендах подписи PAdES: Windows CNG, macOS Keychain и PKCS#11. RFC 4055 §3.1 даёт каждому полю RSASSA-PSS-params явный тег контекста — с [0] по [3], — а бэкенды выдавали saltLength как голый универсальный INTEGER и при этом записывали trailerField, равный своему значению по умолчанию. Байты подписи были верны всё это время. Неверен был AlgorithmIdentifier, их описывающий, и одного этого достаточно, чтобы проверяющий отверг подпись

Самое досадное — где прячется баг. У подписи CMS две половины: криптографическая операция и ASN.1, который говорит проверяющему, как эта операция выполнена. Сделайте первую верно, а вторую неверно — и получится документ, который ни один следующий спецификации инструмент не отличит от подделки. Эта статья только про вторую половину: как должны быть помечены поля RSASSA-PSS-params, как три бэкенда ошиблись одинаковым образом и как выглядит исправленный DER в терминах TDerWriter

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

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

Так что когда PDFium Component подписывает через SHA-256, MGF1 поверх SHA-256 и 32-байтовую соль, эти три факта должны пережить кодирование DER здесь и декодирование DER в другой реализации. Блок параметров, который проверяющий не может разобрать, обрывает проверку до всякого модульного возведения в степень. Блок, который он разбирает иначе, хуже, потому что RFC 4055 §3.1 даёт saltLength значение по умолчанию 20. Декодер, пропускающий нераспознанное поле, попадает на это умолчание, запускает EMSA-PSS-VERIFY с 20-байтовой солью против подписи, вычисленной с 32, и сообщает о плохой подписи без единого намёка на то, что проблема в метаданных, а не в ключе. Оба исхода — то, что давало кодирование 3.114.19, смотря насколько строг был проверяющий, и ни один из них не указывает на AlgorithmIdentifier

Что RFC 4055 §3.1 на самом деле требует от RSASSA-PSS-params

RFC 4055 §3.1 определяет RSASSA-PSS-params как SEQUENCE из четырёх полей, каждое из которых несёт явный тег контекста и значение по умолчанию:

// RSASSA-PSS-params ::= SEQUENCE {
//   hashAlgorithm      [0] HashAlgorithm      DEFAULT sha1,
//   maskGenAlgorithm   [1] MaskGenAlgorithm   DEFAULT mgf1SHA1,
//   saltLength         [2] INTEGER            DEFAULT 20,
//   trailerField       [3] TrailerField       DEFAULT trailerFieldBC
// }

Явное тегирование в DER означает, что каждое поле заворачивается в constructed TLV контекста: A0 для [0], A1 для [1], A2 для [2] и A3 для [3], а внутрь вкладывается универсальное кодирование значения. Каждое поле помечено именно потому, что каждое необязательно через своё умолчание. Без тегов декодер не смог бы сказать, несёт ли SEQUENCE с одним AlgorithmIdentifier поле hashAlgorithm или maskGenAlgorithm, ведь оба — типы SEQUENCE; с тегами номер тега опознаёт поле независимо от того, какие соседи присутствуют. Значения, которые выдаёт PDFium Component, следуют профилю из ETSI TS 119 312 §7 — SHA-256, MGF1 с SHA-256 и соль, равная длине дайджеста, — и в точности повторяют то, что сообщается каждому платформенному вызову подписи: BCRYPT_PSS_PADDING_INFO с cbSalt 32 для NCryptSignHash, CK_RSA_PKCS_PSS_PARAMS с sLen 32 для механизма PKCS#11 и SHA-256-профиль PSS в Security framework

Схема 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 опускает его целиком
Каждое поле помечено именно потому, что каждое необязательно через своё умолчание, так что номер тега опознаёт поле независимо от того, каких соседей кодировщик оставил за бортом

Как три бэкенда ошиблись одинаково

Кодирование 3.114.19 помечало первые два поля и оставляло последние два голыми — одинаково в TWinCmsSigner, TKeychainCmsSigner и TPkcs11CmsSigner. Такая симметрия не совпадение: все три реализуют интерфейс ICmsSigner из FPdfCms.pas, и их тела GetSignatureAlgorithmParams были написаны по одному шаблону. Шаблон выглядел так:

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

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

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

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

Потому что X.690 §11.5 говорит, что кодировщик DER не должен кодировать компонент, чьё значение равно его DEFAULT, а у trailerField DEFAULT — trailerFieldBC, то есть целое 1. Очевидная правка старого кода — замена голого W.IntegerOf(1) на W.ContextSpecific(3, W.IntegerOf(1), True) — даёт блок, который снисходительный декодер BER примет, а строгий декодер DER вправе отвергнуть. Неверно не значение. Неверно его присутствие. По тому же правилу остальные три поля присутствуют: SHA-256 не умолчание sha1, MGF1 с SHA-256 не умолчание mgf1SHA1, а 32 не умолчание 20. Если бы бэкенд подписывал через SHA-1 и 20-байтовую соль, RFC 4055 §3.1 свернул бы параметры в пустую SEQUENCE, 30 00, и именно эту пустую SEQUENCE, а не NULL, ожидает проверяющий. PDFium Component такой формы не выдаёт никогда, потому что никогда не подписывает такими значениями, но именно этот случай ловит всякого, кто полагает, будто «без параметров» всегда пишется как 05 00

Это то различие между DER и BER, которое важно именно для подписей. BER позволяет кодировщику включить компонент со значением по умолчанию; DER это запрещает, потому что DER существует ради того, чтобы у одного значения было ровно одно кодирование, а подпись над структурой с двумя законными кодированиями — это подпись, о которой можно спорить. Всё внутри signedAttrs в CMS сделано DER именно по этой причине, а блок параметров едет внутри signedAttrs через атрибут cmsAlgorithmProtection так же, как и во внешнем signatureAlgorithm, так что исключения для него нет

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

Исправленное кодирование через TDerWriter

Теперь PDFium Component строит параметры тремя вызовами TDerWriter.ContextSpecific из FPdfAsn1.pas, по одному на поле, отличное от умолчания, каждый с аргументом Constructed, выставленным в True, чтобы получить обёртку явного тега, и вообще без строки для поля trailer. Вот тело TWinCmsSigner.GetSignatureAlgorithmParams с выписанными OID; юниты Keychain и PKCS#11 пишут те же значения как OID_SHA256, OID_MGF1 и OID_RSASSA_PSS:

function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
  W: TDerWriter;
begin
  if FPaddingScheme = psRsaPss then
  begin
    W := TDerWriter.Create;
    try
      // RFC 4055 3.1 помечает все четыре поля. saltLength — это [2]; голый
      // INTEGER здесь читается как начало другого поля. trailerField —
      // это [3] с DEFAULT 1, а 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
  else
    Result := nil;   // PKCS#1 v1.5 и ECDSA: AlgIdWithParams пишет NULL
end;

Две детали окружающей машинерии важны. TDerWriter.AlgId выдаёт AlgorithmIdentifier с параметрами NULL, а именно это RFC 4055 §2.1 предписывает кодировщикам генерировать для вложенного hashAlgorithm и для внутреннего хеша MGF1. А сборщик CMS в FPdfCms.pas соединяет OID подписи с этими байтами через TDerWriter.AlgIdWithParams, который подставляет NULL, когда параметры равны nil; поэтому psRsaPkcs1v15 и psEcdsa просто возвращают nil и никогда не были затронуты, и поэтому 1.2.840.113549.1.1.10, id-RSASSA-PSS, — единственный из трёх OID подписи, несущий настоящий блок параметров. Получающиеся байты для профиля SHA-256 фиксированы и достаточно коротки, чтобы проверить их глазами: внешняя SEQUENCE 30 34 с A0 0F вокруг 15-байтового AlgorithmIdentifier для SHA-256, A1 1C вокруг 28-байтового AlgorithmIdentifier для MGF1, чьи собственные параметры — тот же AlgorithmIdentifier SHA-256, и A2 03 02 01 20 для соли. Если дамп вашего signatureAlgorithm показывает 02 01 20 на верхнем уровне SEQUENCE параметров, а не внутри A2, вы смотрите на кодирование 3.114.19

Почему набор тестов не поймал испорченный AlgorithmIdentifier?

Потому что тесты PAdES прогоняют сборщик CMS через поддельного подписанта, который сообщает sha256WithRSAEncryption и возвращает nil из GetSignatureAlgorithmParams, так что блок параметров PSS в тестах не строился вообще. Для тестов, которым нужно работать без хранилища сертификатов, Keychain или токена, это разумное решение, и у него есть слепое пятно вполне определённой формы: всё, что производит только настоящий бэкенд, упражняется только настоящим бэкендом. Второй слой интереснее. PDFium Component ещё и кладёт AlgorithmIdentifier подписи вместе с параметрами внутрь подписанного атрибута cmsAlgorithmProtection из RFC 6211, а проверяющий сравнивает эту копию с внешним signatureAlgorithm. Обе копии пришли из одного вызова, так что совпадали идеально и все внутренние проверки согласованности проходили. Кодирование было самосогласованным и неверным — категория багов, которую никакое сравнение структуры с самой собой не вскроет, — и тот же урок на другой структуре рассказан в материале про CMS signedAttrs и сортировку DER SET OF, где SET, хешированный в одном порядке и выданный в другом, выглядел нормально, пока посторонний проверяющий не пересчитал хеш

А ловит этот класс багов декодер, написанный не автором кодировщика, запущенный против настоящего вывода настоящего бэкенда. Путь проверки в Windows у PDFium Component идёт через CryptoAPI, а не через собственный читатель библиотеки, и именно отвергнутая там подпись PSS привела обратно к параметрам. Любой ASN.1, который реализация выдаёт на чтение другим реализациям, заслуживает хотя бы одного кругового рейса через декодер, которым она не управляет, и чем больше в структуре умолчаний и тегов, тем ценнее этот рейс

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

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

Подписант, объявляющий PSS, берёт на себя обязательство, которого у подписанта v1.5 никогда не было: описать свои параметры в такой форме, чтобы другая реализация декодировала их в те же три значения. RFC 4055 §3.1 фиксирует теги, X.690 §11.5 фиксирует, какие поля могут присутствовать, а ETSI TS 119 312 §7 фиксирует значения, которые стоит выбирать. Все три бэкенда компонента PDFium для Delphi поставляются исходником, так что тело GetSignatureAlgorithmParams выше — то, которое можно прочитать, сдампить и сравнить со своим проверяющим, а не принимать на веру