PDFium Component строит блок CMS signedAttrs через TDerWriter.SetOfSorted, потому что пункт 11.6 стандарта X.690 требует эмитировать члены SET OF в порядке возрастания их собственных DER-кодировок. Соберите их в порядке конструирования — и получите подписи, которые принимает ваш собственный верификатор и отвергает любой сторонний PAdES-валидатор
Последнее предложение — и есть причина, по которой этот дефект пережил полный регрессионный прогон. Путь подписания был покрыт, путь проверки был покрыт, round-trip был зелёным на всех платформах, а вывод всё равно не был валидным DER. Тест, который подписывает вашим писателем и проверяет вашим читателем, не может обнаружить отклонение от правил кодирования, потому что обе половины разделяют это отклонение
Что на самом деле требует пункт 11.6 стандарта X.690
Члены значения SET OF должны быть отсортированы, и ключ сортировки — полная DER-кодировка каждого члена, сравниваемая как беззнаковая октетная строка, причём более короткая кодировка идёт первой, когда одна является префиксом другой. Это каноническое ограничение, а не рекомендация: DER допускает ровно одну кодировку данного значения, так что несортированный SET OF — это не иначе упорядоченная кодировка того же значения, это вообще не DER-кодировка. Поскольку в TLV теги ASN.1 идут первыми, сравнение обычно разрешается внутри первых двух октетов, а так как каждый член списка атрибутов CMS начинается с 30 — тега SEQUENCE — спор решает октет длины. Поэтому требуемый порядок почти выглядит как сортировка атрибутов по размеру, что полезно знать, когда вы смотрите в hex-дамп и гадаете, почему порядок кажется произвольным
Почему минимальный набор атрибутов уже не в порядке?
Потому что естественный порядок конструирования и порядок кодирования расходятся с самой первой подписи, которую вы производите, без участия каких-либо опциональных атрибутов. BuildSignedAttrs в FPdfCms.pas добавляет content-type, затем message-digest, затем signing-certificate-v2, затем cms-algorithm-protection. При SHA-256-дайджестах и RSA-алгоритме подписи четыре TLV атрибутов начинаются с этих байтов
// Порядок конструирования, который производит BuildSignedAttrs
// content-type 30 18 (24 content octets)
// message-digest 30 2F (47)
// signing-certificate-v2 30 37 (55)
// cms-algorithm-protection 30 2D (45)
//
// Порядок DER, требуемый X.690 11.6
// 30 18 content-type
// 30 2D cms-algorithm-protection
// 30 2F message-digest
// 30 37 signing-certificate-v2
Два из четырёх атрибутов переезжают. cms-algorithm-protection на 30 2D должен идти перед signing-certificate-v2 на 30 37, и он же должен идти перед message-digest на 30 2F. Так что наименьший конформный набор атрибутов PAdES B-B, который может эмитировать PDFium Component, уже был расставлен в неправильном порядке в старом коде — ещё до того, как кто-то добавит signing-time, commitment-type-indication или signature-policy-identifier. Не существовало конфигурации, в которой баг не срабатывал бы
Почему наши собственные тесты проходят, а все внешние валидаторы падают?
Потому что два семейства верификаторов вычисляют дайджест по разным байтам. Пункт 5.4 RFC 5652 говорит, что вход операции подписи — DER-кодировка значения SignedAttributes, в которой неявный тег IMPLICIT [0] заменён тегом SET 31, и решающая фраза — DER-кодировка, а не полученные вами октеты. Верификатор Windows, до которого добираются через CryptVerifyDetachedMessageSignature, берёт полученные октеты, меняет ведущий тег и хэширует их как пришли — порядок в это вообще не входит, и несортированный SET OF дайджестируется ровно в то, что вычислил подписант. А OpenSSL и BouncyCastle, на которых построены широко развёрнутые европейские валидаторы, парсят набор атрибутов в структуры и перекодируют перед дайджестированием. Перекодировка применяет сортировку, дайджест больше не совпадает с подписанным, и подпись докладывается как сломанная, а не как неверно закодированная, что отправляет вас рыскать по материалам ключей и цепочкам сертификатов в поисках дефекта, который живёт в функции сравнения
Обобщите дальше этого одного бага, потому что это и есть дорогая часть. Round-trip одного происхождения иммунен к любому отклонению от интероперабельностного спека, которое разделяют обе половины вашего кода; он доказывает, что ваш писатель и ваш читатель согласны друг с другом, а это не то свойство, которое кого-то волнует. Для всего, что покидает процесс как сетевой формат, единственный осмысленный регресс — тот, что прибивает байты к спеку или прогоняет чужую реализацию по вашему выводу. Бэкенд верификации CMS на OpenSSL заслуживает здесь своего места и по второй причине, помимо переносимости: это по-настоящему независимый читатель вашего собственного DER
Фикс, и где он уже существовал
Сортированная разновидность в DER-писателе была всё это время. TDerWriter в FPdfAsn1.pas выставляет и SetOf, который оборачивает предварительно сконкатенированный буфер содержимого в тег SET и сохраняет любой порядок, которым его наделили, и SetOfSorted, который принимает членов по отдельности как TDerByteArrays и merge-сортирует их по кодировкам перед оборачиванием. Код MAC для PDF в FPdfMac.pas уже строил свой набор аутентифицированных атрибутов через SetOfSorted; не делал этого только CMS-подписант. Держать членов массивом вплоть до вызова оборачивания — структурная половина фикса, потому что после конкатенации атрибутов в единый буфер информация о порядке исчезает и никакая забота на нижних уровнях её не восстановит. API писателя, предлагающий для SET OF только SetOf(Content: TBytes), — это API, приглашающий этот баг; перегрузка, принимающая массив, — то, что делает правильное поведение выразимым
// До: члены сохраняют порядок, в котором их добавил AddAttr
Result:= W.SetOf(ConcatMany(AttrList));
// После: порядок X.690 11.6 применяется в точке кодирования
Result:= W.SetOfSorted(AttrList);
SET OF сортируется, SEQUENCE OF — нет
Это различие путают чаще всего, и перепутать его — так же разрушительно, как и проигнорировать. SEQUENCE OF — упорядоченная коллекция: её порядок — часть значения, и сортировать её нельзя никогда. SET OF как значение неупорядочен, поэтому DER навязывает канонический порядок, чтобы кодировка была единственной. Сортировка SEQUENCE OF портит данные; несортированный SET OF производит не-DER
- SET OF, сортировать обязательно:
SignedAttributesиUnsignedAttributesвSignerInfo, наборdigestAlgorithms, наборыcertificatesиcrlsвSignedData, наборsignerInfos, набор значений внутри каждого отдельногоAttributeи набор атрибутов внутри каждогоRelativeDistinguishedNameимени X.501 - SEQUENCE OF, не сортировать никогда: список RDN, из которых состоит
Name, полеcertsвSigningCertificateV2, списки расширений и списки general-name внутри них
RDN — это SET OF, сидящий внутри SEQUENCE OF уровнем выше, так что различающее имя содержит оба правила в двух уровнях вложенности. Та же раскладка объясняет, почему одноэлементные наборы так снисходительны: множество с одним элементом тривиально отсортировано, из-за чего наборы значений атрибутов, построенные через W.SetOf внутри BuildSignedAttrs, были корректны как написаны и не требовали изменений. Баг живёт только там, где множество реально держит два и более членов, а RDN с несколькими значениями достаточно редки, чтобы большинство кодеров имён годами сходило с рук с той же ошибкой
Как не допустить, чтобы это вернулось?
Ассертьте байты, а не вердикты. Самый дешёвый долговечный чек — самоаудит, проходящий по собственному выводу: распарсите эмитированный signedAttrs, сравните каждого члена с предыдущим тем же сравнением беззнаковых октетных строк, которое использует писатель, и валите сборку, если любая пара не в порядке. Он выполняется за микросекунды, не требует внешних библиотек и, в отличие от верификационного round-trip, проверяет то свойство, которое спецификация на самом деле утверждает
// Пройдитесь по членам SET OF и подтвердите порядок X.690 11.6.
// Members — список, переданный в SetOfSorted, или результат
// повторного парсинга эмитированного блока 31 xx / A0 xx
function SetOfIsSorted(const Members: TDerByteArrays): Boolean;
var
I: Integer;
begin
for I:= 1 to Length(Members)- 1 do
if CompareDerBytes(Members[I- 1], Members[I])> 0 then
Exit(False);
Result:= True;
end;
// В тесте подписания не останавливайтесь на «наш верификатор сказал валидно».
// Прибейте байты, которые хэшировал подписант
Attrs:= BuildSignedAttrs(W, Digest, CertHash, DigestOid, SigOid, nil, Options);
Assert(Attrs[0]= $31, 'signedAttrs must carry the SET tag when hashed');
Assert(SetOfIsSorted(SplitSetOfMembers(Attrs)), 'signedAttrs not in DER order');
Два ограничения стоит проговорить прямо. Сортировка signedAttrs сама по себе не делает подпись интероперабельной: всё остальное в контейнере тоже должно держаться, а причины, по которым валидаторы отворачиваются от иначе здоровых PAdES-подписей, достаточно разнообразны, чтобы заполнить отдельное обсуждение. И отсортированный SET OF — свойство того, что вы пишете, а не того, что читаете: PDFium Component всё ещё обязан принять входящую подпись, чьи signedAttrs пришли несортированными, потому что такие файлы существуют в природе, а пункт 5.4 RFC 5652 велит верификатору перекодировать, а не отвергать. Строго на выходе, терпимо на входе, как обычно. DER-писатель, CMS-билдер и путь подписания, описанные в проходе по PAdES B-B, поставляются как читаемый исходник Pascal вместе с PDFium Delphi component — а это и есть весь смысл поставки исходников для библиотеки подписания: когда какой-нибудь валидатор в Европе не соглашается с вашим документом, вы можете прочитать точные байты, которые эмитил ваш код, и точный пункт, который они нарушают, вместо того чтобы заводить тикет и ждать