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 із чотирьох полів, кожне з яких несе явний контекстно-залежний тег і значення DEFAULT:
// 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 означає, що кожне поле загорнуте в конструктивний контекстно-залежний 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
Як три бекенди зробили ту саму помилку
Кодування 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-байтової солі. Спільний шаблон ефективний, коли він правильний, і так само ефективно веде до помилки тричі, коли ні, — саме тому виправлення лягло в усі три юніти одним комітом і тому три тіла методів після нього лишаються структурно ідентичними. Майбутній бекенд має скопіювати цей блок з одного з них, а не виводити його заново, бо помилку зробили саме на виведенні
Чому 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 існує для того, щоб одне значення мало рівно одне кодування, а підпис над структурою з двома легальними кодуваннями — це підпис, про який можна сперечатися. Усе всередині CMS signedAttrs — це DER саме тому, і блок параметрів подорожує всередині signedAttrs через атрибут cmsAlgorithmProtection так само, як і в зовнішньому signatureAlgorithm, тож жодного винятку для нього немає
Виправлене кодування 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, який реалізація видає для читання іншими реалізаціями, заслуговує щонайменше одного round-trip через декодер, який вона не контролює, і чим більше в структурі типових значень і тегів, тим більше вартий цей round-trip
Як це вписується в решту історії 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 component постачаються як вихідний код, тож тіло GetSignatureAlgorithmParams вище — це те, що ви можете прочитати, скинути в дамп і порівняти зі своїм валідатором, а не приймати на віру