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

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

PDFium Component версия 3.114.20 поправя кодирането на RSASSA-PSS-params и в трите PAdES подписващи backend-а — Windows CNG, macOS Keychain и PKCS#11. RFC 4055 §3.1 дава на всяко поле на RSASSA-PSS-params изричен контекстно-специфичен таг, [0] до [3], а backend-ите излъчваха saltLength като гол universal INTEGER, като едновременно изписваха trailerField, равен на стойността си по подразбиране. Байтовете на подписа бяха верни през цялото време. AlgorithmIdentifier-ът, описващ ги, не беше, а само това стига на верификатор да отхвърли подписа

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

Защо верификатор отхвърля RSASSA-PSS подпис, чиито байтове са верни?

Защото RSASSA-PSS е единствената RSA схема, при която верификаторът не може да възстанови параметрите от самия подпис. PKCS#1 v1.5 подплатата е напълно определена от OID sha256WithRSAEncryption, така че нейните параметри са гол NULL и няма какво да се обърка. PSS е параметризирана от hash функция, mask generation функция със собствен hash и дължина на солта, а 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 означава, че всяко поле е увито в constructed контекстно-специфичен TLV — A0 за [0], A1 за [1], A2 за [2] и A3 за [3] — с universal кодирането на стойността вложено вътре. Всяко поле е таговано именно защото всяко поле е опционално чрез своя default. Без тагове декодер не би могъл да каже дали 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 digest-подписващият 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 го пропуска изцяло
Всяко поле е таговано именно защото всяко поле е опционално чрез своя default, така че номерът на тага идентифицира полето независимо кои съседи кодерът е изпуснал

Как три backend-а сгрешиха по един и същ начин

Кодирането 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, чете hash алгоритъма, вижда A1, чете mask generation функцията, и после среща 02 01 20. Това е universal INTEGER, а RSASSA-PSS-params няма нито един нетагован INTEGER член никъде. Строг декодер спира там. Снизходителен прескача неразпознатия елемент, никога не намира A2, задава saltLength своята подразбираща се 20, после удря втори странен INTEGER, 02 01 01, и има същия проблем пак. Нито един път не стига до 32-байтова сол. Споделен шаблон е ефективен, когато е верен, и еднакво ефективен начин да сгрешиш три пъти, когато не е — затова поправката кацна в трите единици в един commit и затова трите тела на метода останаха структурно идентични след това. Бъдещ backend трябва да копира блока от един от тези, вместо да го пре-извежда, защото извеждането е точно мястото, където е направена грешката

Диаграма на 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. Ако backend-ът подписваше със 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, така че не получава освобождение

Диаграма на PDFium Component за правилото X.690 11.5 в PSS поправката: trailerField, равен на своя DEFAULT trailerFieldBC, трябва да остане отсъстващ, защото тагования A3 wrapper е кодирането, което строг DER отхвърля, докато saltLength 32 се различава от подразбиращата се 20 и трябва да присъства като A2
DER съществува, така че една стойност да има точно едно кодиране, а компонент, равен на своя default, вече има най-краткото съществуващо кодиране — а именно да не се появява изобщо

Коригираното кодиране на 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 и за вътрешния hash на MGF1. И CMS builder-ът в 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-байтовия SHA-256 AlgorithmIdentifier, A1 1C около 28-байтовия MGF1 AlgorithmIdentifier, чиито собствени параметри са този същия SHA-256 AlgorithmIdentifier, и A2 03 02 01 20 за солта. Ако дъмп на вашия signatureAlgorithm показва 02 01 20 на горното ниво на params SEQUENCE, вместо вътре в A2, гледате кодирането 3.114.19

Защо тестовият suite не хвана повреден AlgorithmIdentifier?

Защото PAdES тестовете задвижват CMS builder-а през фиктивен подписващ, който докладва sha256WithRSAEncryption и връща nil от GetSignatureAlgorithmParams, така че блокът PSS параметри изобщо не беше конструиран в тест. Това е разумен дизайн за тестове, които трябва да вървят без certificate store, Keychain или токен, и има сляпо място с точна форма: каквото само истински backend произвежда, само истински backend упражнява. Вторият слой е по-интересен. PDFium Component също поставя подписния AlgorithmIdentifier, включително параметрите, вътре в подписания атрибут cmsAlgorithmProtection от RFC 6211, а верификатор сравнява това копие срещу външния signatureAlgorithm. И двете копия идваха от същото извикване, така че съвпадаха перфектно и всяка вътрешна consistency проверка минаваше. Кодирането беше самосъгласувано и грешно — категория бъг, който никакво количество сравняване на структура със самата себе си не може да разкрие, а същият урок с различна структура се разказва в CMS signedAttrs и DER SET OF сортиране, където SET, хеширан в един ред и излъчен в друг, изглеждаше наред, докато чужд верификатор не преизчисли hash-а

Това, което хваща тази класа бъгове, е декодер, не написан от автора на кодера, пуснат върху действителния изход на действителния backend. Windows верификационният път в PDFium Component минава през CryptoAPI, а не през собствения четец на библиотеката, и отхвърлен PSS подпис там е това, което поведе обратно към параметрите. Всяко ASN.1, което една имплементация излъчва за други имплементации да четат, заслужава поне един round trip през декодер, който не контролира, и колкото повече defaults и тагове има структурата, толкова повече си струва този round trip

Къде това се вмества с останалата PSS история

Тази поправка е независима от другите две места, където PSS може да се обърка в PAdES подпис, и тяхното разделяне скъсява дебъгването. macOS backend-ът може да открие, че даден ключ или по-стара система отказва PSS и да понижи до PKCS#1 v1.5, а AlgorithmIdentifier-ът трябва да последва понижението; това е въпрос за възможности, покрит в подписване на PAdES с macOS Keychain идентичност. PKCS#11 backend-ът може да подаде на токена 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 фиксира стойностите, които си заслужава да избереш. И трите backend-а на PDFium Delphi компонента се доставят като source, така че телето GetSignatureAlgorithmParams горе е това, което можете да прочетете, да дъмпнете и да сравните със собствения си верификатор, вместо да го приемете на вяра