PDFium Component строи CMS signedAttrs блока през TDerWriter.SetOfSorted, защото X.690 клауза 11.6 изисква членовете на SET OF да се емитират във възходящ ред на собствените си DER кодировки. Сглобиш ли ги в реда на конструиране вместо това, получаваш подписи, които собственият ти verifier приема, а всеки външен PAdES валидатор отхвърля
Това последно изречение е цялата причина този дефект да оцелее през пълен regression комплект. Пътят на подписване беше покрит, пътят на верификация беше покрит, round trip-ът беше зелен на всяка платформа, а изходът пак не беше валиден DER. Тест, който подписва с твоя writer и верифицира с твоя reader, не може да открие отклонение от правилата за кодиране, защото и двете половини споделят отклонението
Какво всъщност изисква X.690 клауза 11.6
Членовете на една SET OF стойност трябва да са сортирани, а сортировъчният ключ е пълната DER кодировка на всеки член, сравнявана като unsigned octet низ, като по-късата кодировка се подрежда първа, когато едната е префикс на другата. Това е канонично ограничение, а не предложение: DER допуска точно една кодировка на дадена стойност, така че несортирана SET OF не е различно подредена кодировка на същата стойност, тя изобщо не е DER кодировка. Понеже ASN.1 таговете идват първи в едно TLV, сравнението обикновено се решава в първите два октета, а тъй като всеки член на CMS атрибутен списък започва с 30, тага на SEQUENCE, равенството се чупи на октета за дължина. Затова изискваният ред изглежда почти като подреждане на атрибутите по размер, което си заслужава да знаеш, докато се взираш в hex dump и се чудиш защо редът изглежда произволен
Защо минималният атрибутен набор вече е в грешен ред?
Защото естественият ред на конструиране и кодираният ред се разминават още от първия подпис, който произвеждаш, без да са замесени никакви опционални атрибути. BuildSignedAttrs в FPdfCms.pas добавя content-type, после message-digest, после signing-certificate-v2, после cms-algorithm-protection. При SHA-256 digest-и и 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. Никога не е имало конфигурация, в която бъгът да не се прояви
Защо нашите собствени тестове минават, а всеки външен валидатор се проваля?
Защото двете семейства verifier-и изчисляват message digest-а върху различни байтове. RFC 5652 клауза 5.4 казва, че входът за операцията по подпис е DER кодировката на стойността SignedAttributes, с IMPLICIT [0] тага, заменен от SET тага 31, а фразата, която решава всичко, е DER кодировката, не получените октети. Windows verifier-ът, достигнат през CryptVerifyDetachedMessageSignature, взема получените октети, сменя водещия таг и ги хешира така, както са пристигнали, така че редът никога не влиза в играта и една грешно подредена SET OF се диджестира точно до това, което подписващият е изчислил. OpenSSL и BouncyCastle, върху които са построени широко разпространените европейски валидатори, парсват атрибутния набор в структури и пре-кодират преди да диджестират. Пре-кодирането прилага сортирането, digest-ът вече не съвпада с подписания и подписът се докладва като счупен, а не като грешно кодиран, което те изпраща да търсиш из ключов материал и сертификатни вериги дефект, който живее в една сравняваща функция
Обобщи отвъд този един бъг, защото това е скъпата част. Един same-origin round trip е имунизиран срещу всяко отклонение от интероперационна спецификация, което и двете половини на кода ти споделят; той доказва, че твоят writer и твоят reader са съгласни помежду си, което не е свойството, за което някой го е грижа. За всичко, което напуска процеса като wire формат, единственият смислен regression е този, който заковава байтовете срещу спецификацията или пуска чужда имплементация върху изхода ти. OpenSSL CMS verification backend-ът заслужава мястото си тук по втора причина отвъд преносимостта: той е наистина независим reader на собствения ти DER
Поправката и къде вече е съществувала
DER writer-ът е имал сортирания вариант през цялото време. TDerWriter в FPdfAsn1.pas излага и SetOf, който обвива предварително конкатениран content буфер в SET таг и запазва какъвто и ред да му е подан, и SetOfSorted, който приема членовете поотделно като TDerByteArrays и ги merge-сортира по кодировките им преди обвиването. PDF MAC кодът в FPdfMac.pas вече е строял своя authenticated attribute набор със SetOfSorted; само CMS подписващият не е. Запазването на членовете като масив чак до извикването на обвиването е структурната половина на поправката, защото веднъж конкатенирани ли са атрибутите в един буфер, информацията за реда изчезва и никаква грижа надолу не може да я възстанови. Writer API, който за SET OF предлага само SetOf(Content: TBytes), е API, който кани този бъг; overload-ът, приемащ масив, е това, което прави правилното нещо изразимо
// Преди: членовете запазват реда, в който 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 Name - SEQUENCE OF, никога не се сортира: списъкът от RDN, изграждащ
Name, полетоcertsнаSigningCertificateV2, списъците от extension-и и списъците от general-names вътре в тях
Един RDN е SET OF, седяща вътре в SEQUENCE OF едно ниво нагоре, така че едно distinguished name съдържа и двете правила в две нива на влагане. Това разположение също обяснява защо едноелементните набори са толкова прощаващи: набор с един елемент е тривиално сортиран, което е причината атрибутните набори от стойности, построени с W.SetOf вътре в BuildSignedAttrs, да са били правилни както са написани и да не са имали нужда от промяна. Бъгът живее само там, където наборът наистина държи два или повече члена, а multi-valued RDN са достатъчно редки, така че повечето name енкодери се измъкват със същата грешка с години
Как да не позволиш това да се върне?
Асъртвай върху байтове, не върху присъди. Най-евтината дълготрайна проверка е самоодит, който обхожда собствения си изход: парсни емитирания signedAttrs, сравни всеки член с предишния със същото unsigned-octet-string сравнение, което writer-ът използва, и провали сборката, ако някоя двойка е в грешен ред. Върви за микросекунди, не иска външна библиотека и за разлика от verification 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;
// В signing теста не спирай до „нашият verifier каза валидно".
// Заковай байтовете, които подписващият е хеширал
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 пристига несортиран, защото такива файлове съществуват в дивата природа и RFC 5652 клауза 5.4 казва на verifier-а да пре-кодира, а не да отхвърли. Строг на изхода, толерантен на входа, както винаги. DER writer-ът, CMS builder-ът и пътят на подписване, описан в PAdES B-B walkthrough-а, всички се доставят като четими Pascal източници с PDFium Delphi компонента, което е самият смисъл да доставяш изходен код за библиотека за подписване: когато някой валидатор някъде в Европа не е съгласен с документа ти, можеш да прочетеш точните байтове, които кодът ти е емитирал, и точната клауза, която те нарушават, вместо да отвориш тикет и да чакаш