مقاله فنی

کدگذاری RSASSA-PSS-params طبق RFC 4055 در PDFium Delphi

کامپوننت PDFium نسخهٔ 3.114.20 کدگذاری RSASSA-PSS-params را در هر سه backend امضای PAdES اصلاح می‌کند: Windows CNG و macOS Keychain و PKCS#11. RFC 4055 §3.1 به هر فیلد RSASSA-PSS-params یک تگ صریح context-specific می‌دهد، از [0] تا [3]، و آن backendها saltLength را به‌صورت یک INTEGER جهانی برهنه بیرون می‌دادند در حالی که یک trailerField برابر با پیش‌فرضش را هم می‌نوشتند. بایت‌های امضا تمام این مدت درست بودند. خود AlgorithmIdentifier که آن‌ها را توصیف می‌کرد نبود، و همین به‌تنهایی برای یک verifier کافی است که امضا را رد کند

بخش آزاردهنده جایی است که باگ پنهان می‌شود. یک امضای CMS دو نیمه دارد: عملیات رمزنگاری، و همان ASN.1 که به verifier می‌گوید عملیات چطور انجام شده. اولی را درست بگیر و دومی را غلط، و نتیجه سندی است که هیچ ابزار پایبند به مشخصات نمی‌تواند از یک جعل تشخیصش بدهد. این مقاله فقط دربارهٔ همان نیمهٔ دوم است: RSASSA-PSS-params چطور باید تگ بخورد، سه backend چطور به یک شکل غلطش گرفتند، و DER اصلاح‌شده به زبان TDerWriter چه شکلی است

چرا یک verifier امضای RSASSA-PSSی را که بایت‌هایش درست است رد می‌کند؟

چون RSASSA-PSS تنها طرح RSAای است که در آن verifier نمی‌تواند پارامترها را از خود امضا بازیابی کند. پدینگ PKCS#1 v1.5 کاملاً با OID مربوط به sha256WithRSAEncryption تعیین می‌شود، پس پارامترهایش یک NULL برهنه است و چیزی برای غلط گرفتن وجود ندارد. PSS با یک تابع هش، یک تابع تولید ماسک که خودش هش جدا دارد، و یک طول salt پارامتری می‌شود، و RFC 8017 §A.2.3 هر سه را باز می‌گذارد. امضاکننده انتخابشان می‌کند، AlgorithmIdentifier حملشان می‌کند، و verifier باید دقیقاً بازتولیدشان کند پیش از آن‌که EMSA-PSS-VERIFY اصلاً بتواند شروع شود

پس وقتی کامپوننت PDFium با SHA-256 و MGF1 روی SHA-256 و یک salt 32 بایتی امضا می‌کند، آن سه واقعیت باید کدگذاری DER را این‌جا و decode DER را روی یک پیاده‌سازی دیگر جان سالم به در ببرند. یک بلوک پارامتر که verifier نمی‌تواند پارسش کند، تأیید را پیش از آن‌که هیچ توان‌سنجی پیمانه‌ای رخ بدهد تمام می‌کند. بلوکی که متفاوت پارسش کند بدتر است، چون RFC 4055 §3.1 به saltLength پیش‌فرض 20 می‌دهد. decoderی که فیلدی را که نمی‌شناسد رد کند روی همان پیش‌فرض می‌نشیند، EMSA-PSS-VERIFY را با یک salt 20 بایتی در برابر امضایی که با 32 محاسبه شده اجرا می‌کند، و یک امضای خراب گزارش می‌دهد بدون هیچ اشاره‌ای به این‌که مشکل متادیتا است نه کلید. هر دو نتیجه همان چیزی است که کدگذاری 3.114.19 تولید می‌کرد، بسته به این‌که verifier چقدر سختگیر بوده، و هیچ‌کدام به AlgorithmIdentifier اشاره نمی‌کند

RFC 4055 §3.1 واقعاً چه چیزی از RSASSA-PSS-params می‌خواهد

RFC 4055 §3.1 ساختار RSASSA-PSS-params را به‌عنوان یک SEQUENCE از چهار فیلد تعریف می‌کند، هر کدام با یک تگ صریح context-specific و یک مقدار 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 ساخته‌شدهٔ context-specific پیچیده می‌شود، A0 برای [0] و A1 برای [1] و A2 برای [2] و A3 برای [3]، با کدگذاری جهانی مقدار به‌صورت تودرتو در داخلش. هر فیلد دقیقاً به این دلیل تگ می‌خورد که هر فیلد از طریق پیش‌فرضش اختیاری است. بدون تگ یک decoder نمی‌تواند تشخیص بدهد که یک SEQUENCE حاوی یک AlgorithmIdentifier تکی دارد hashAlgorithm را حمل می‌کند یا maskGenAlgorithm را، چون هر دو از نوع SEQUENCEاند؛ با تگ‌ها، شمارهٔ تگ فیلد را مشخص می‌کند مستقل از این‌که کدام همسایه‌ها حاضرند. مقدارهایی که کامپوننت PDFium بیرون می‌دهد از پروفایل ETSI TS 119 312 §7 پیروی می‌کنند: SHA-256 و MGF1 با SHA-256 و یک salt برابر طول digest، و دقیقاً همان چیزی را بازتاب می‌دهند که به هر فراخوانی امضای پلتفرم گفته می‌شود: یک BCRYPT_PSS_PADDING_INFO با cbSalt برابر 32 برای NCryptSignHash، یک CK_RSA_PKCS_PSS_PARAMS با sLen برابر 32 برای مکانیزم PKCS#11، و الگوریتم PSS امضای digest با SHA-256 در framework امنیتی

نمودار کامپوننت PDFium از RSASSA-PSS-params طبق RFC 4055: فیلدهای hashAlgorithm و maskGenAlgorithm و saltLength به‌صورت A0 و A1 و A2 تگ‌های صریح context-specific با مقدارهای DEFAULT حمل می‌کنند، پروفایل ETSI مقدار SHA-256 و MGF1 با SHA-256 و salt 32 بیرون می‌دهد، و trailerField به‌صورت A3 برابر trailerFieldBC است پس DER کاملاً حذفش می‌کند
هر فیلد دقیقاً به این دلیل تگ می‌خورد که هر فیلد از طریق پیش‌فرضش اختیاری است، پس شمارهٔ تگ فیلد را مشخص می‌کند هر کدام از همسایه‌ها را که encoder بیرون بگذارد

سه 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 است باید غایب باشد

decoderی که آن SEQUENCE را می‌پیماید A0 را می‌بیند، الگوریتم هش را می‌خواند، A1 را می‌بیند، تابع تولید ماسک را می‌خواند، و بعد به 02 01 20 می‌رسد. این یک INTEGER جهانی است، و RSASSA-PSS-params هیچ عضو INTEGER تگ‌نخورده‌ای در هیچ جا ندارد. یک decoder سختگیر همان‌جا می‌ایستد. یک decoder ملایم عنصر ناشناخته را رد می‌کند، هیچ‌وقت A2 پیدا نمی‌کند، به saltLength پیش‌فرض 20 را نسبت می‌دهد، و بعد به یک INTEGER سرگردان دوم می‌خورد، 02 01 01، و همان مشکل را دوباره دارد. هیچ‌کدام از دو مسیر به salt 32 بایتی نمی‌رسد. یک قالب مشترک وقتی درست باشد کارآمد است و وقتی نباشد راهی به همان اندازه کارآمد برای غلط بودن سه بار، و همین دلیل است که fix در هر سه unit و در یک commit نشست و دلیل این‌که آن سه بدنهٔ متد بعد از آن هم ساختاراً یکسان می‌مانند. یک backend آینده باید این بلوک را از یکی از این‌ها کپی کند نه این‌که از نو استخراجش کند، چون استخراج دقیقاً همان جایی است که اشتباه رخ داد

نمودار کامپوننت PDFium از باگ DER در 3.114.19: بعد از A0 و A1 پارامترها به یک 02 01 20 برهنه خوردند جایی که تگ صریح A2 تعلق دارد، یک decoder سختگیر ایستاد و یکی ملایم با salt پیش‌فرض 20 بایتی امضا کرد، در حالی که 3.114.20 آن salt 32 بایتی را در A2 می‌پیچد
RSASSA-PSS-params هیچ عضو INTEGER تگ‌نخورده‌ای ندارد، پس آن بایت‌های سرگردان یا یک شکست پارس بودند یا یک بازگشت بی‌صدا به طول salt پیش‌فرض، و هیچ‌کدام از دو مسیر به 32 امضاکننده نرسید

چرا trailerField حذف می‌شود به‌جای این‌که به‌عنوان [3] تگ بخورد؟

چون X.690 §11.5 می‌گوید یک encoder DER نباید مؤلفه‌ای را کد کند که مقدارش با DEFAULTش برابر است، و trailerField مقدار DEFAULTش trailerFieldBC است که همان عدد صحیح 1 است. تصحیح بدیهی کد قدیمی، یعنی جایگزین کردن W.IntegerOf(1) برهنه با W.ContextSpecific(3, W.IntegerOf(1), True)، بلوکی می‌دهد که یک decoder آسانگیر BER قبولش می‌کند و یک decoder سختگیر DER حق دارد ردش کند. مقدارش غلط نیست. حضورش غلط است. همین قاعده دلیل است که آن سه فیلد دیگر حاضرند: SHA-256 پیش‌فرض sha1 نیست، MGF1 با SHA-256 پیش‌فرض mgf1SHA1 نیست، و 32 پیش‌فرض 20 نیست. اگر backend با SHA-1 و یک salt 20 بایتی امضا می‌کرد، RFC 4055 §3.1 پارامترها را به یک SEQUENCE خالی فرو می‌ریخت، 30 00، و verifier به‌جای NULL همان SEQUENCE خالی را انتظار دارد. کامپوننت PDFium هرگز آن شکل را بیرون نمی‌دهد چون هرگز با آن مقدارها امضا نمی‌کند، ولی همان حالتی است که هر کسی را که فرض کند «بدون پارامتر» همیشه 05 00 نوشته می‌شود به دام می‌اندازد

این همان تفاوت DER و BER است که به‌طور خاص برای امضاها مهم می‌شود. BER به encoder اجازه می‌دهد یک مؤلفهٔ دارای مقدار پیش‌فرض را بگنجاند؛ DER منعش می‌کند، چون DER وجود دارد تا یک مقدار دقیقاً یک کدگذاری داشته باشد، و امضایی روی ساختاری با دو کدگذاری مجاز امضایی است که می‌توان سرش دعوا کرد. به همین دلیل همه‌چیز داخل signedAttrs در CMS از نوع DER است، و بلوک پارامتر هم از طریق اتریبیوت cmsAlgorithmProtection و هم در signatureAlgorithm بیرونی داخل signedAttrs سفر می‌کند، پس هیچ معافیتی نمی‌گیرد

نمودار کامپوننت PDFium از قاعدهٔ X.690 11.5 در fix مربوط به PSS: trailerField که با DEFAULTش یعنی trailerFieldBC برابر است باید غایب بماند چون یک پوشش A3 تگ‌خورده همان کدگذاری‌ای است که DER سختگیر ردش می‌کند، در حالی که saltLength برابر 32 با پیش‌فرض 20 فرق دارد و باید به‌صورت A2 حاضر باشد
DER وجود دارد تا یک مقدار دقیقاً یک کدگذاری داشته باشد، و مؤلفه‌ای که با پیش‌فرضش برابر است از قبل کوتاه‌ترین کدگذاری موجود را دارد، یعنی اصلاً ظاهر نشدن

کدگذاری اصلاح‌شده در TDerWriter

کامپوننت PDFium حالا پارامترها را با سه فراخوانی TDerWriter.ContextSpecific از FPdfAsn1.pas می‌سازد، یکی به ازای هر فیلد غیر-پیش‌فرض، هر کدام با آرگومان Constructed روی True تا پوشش تگ-صریح تولید شود، و اصلاً هیچ خطی برای فیلد trailer. این بدنهٔ TWinCmsSigner.GetSignatureAlgorithmParams است با OIDهای صریح نوشته‌شده؛ unitهای 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 به encoderها می‌گوید برای hashAlgorithm تودرتو و برای هش درونی MGF1 تولید کنند. و سازندهٔ CMS در FPdfCms.pas OID امضا را از طریق TDerWriter.AlgIdWithParams با این بایت‌ها جفت می‌کند، که وقتی پارامترها nil باشند NULL جایگزین می‌کند؛ همین دلیل است که psRsaPkcs1v15 و psEcdsa به‌سادگی nil برمی‌گردانند و هرگز تحت تأثیر نبودند، و دلیل این‌که 1.2.840.113549.1.1.10 یعنی id-RSASSA-PSS تنها OID امضای میان آن سه است که یک بلوک پارامتر واقعی حمل می‌کند. بایت‌های حاصل برای پروفایل SHA-256 ثابت و آن‌قدر کوتاه‌اند که با چشم بررسی شوند: یک SEQUENCE بیرونی 30 34 که A0 0F را دور AlgorithmIdentifier پانزده‌بایتی SHA-256 نگه می‌دارد، A1 1C را دور AlgorithmIdentifier بیست‌وهشت‌بایتی MGF1 که پارامترهای خودش همان AlgorithmIdentifier SHA-256 است، و A2 03 02 01 20 برای salt. اگر dumpی از signatureAlgorithm تو 02 01 20 را در سطح بالای SEQUENCE پارامترها نشان بدهد نه داخل یک A2، داری به کدگذاری 3.114.19 نگاه می‌کنی

چرا مجموعهٔ تست یک AlgorithmIdentifier بدشکل را نگرفت؟

چون تست‌های PAdES سازندهٔ CMS را از طریق یک امضاکنندهٔ جعلی می‌چرخانند که sha256WithRSAEncryption گزارش می‌کند و از GetSignatureAlgorithmParams مقدار nil برمی‌گرداند، پس بلوک پارامترهای PSS اصلاً در هیچ تستی ساخته نشد. این طراحی معقولی است برای تست‌هایی که باید بدون مخزن گواهی یا Keychain یا توکن اجرا شوند، و یک نقطهٔ کور با شکلی دقیق دارد: هر چیزی که فقط یک backend واقعی تولیدش می‌کند، فقط با یک backend واقعی تمرین می‌شود. لایهٔ دوم جالب‌تر است. کامپوننت PDFium همچنین AlgorithmIdentifier امضا را، با پارامترهایش، داخل اتریبیوت امضاشدهٔ cmsAlgorithmProtection از RFC 6211 می‌گذارد، و یک verifier آن نسخه را با signatureAlgorithm بیرونی مقایسه می‌کند. هر دو نسخه از همان یک فراخوانی آمده بودند، پس کاملاً مطابقت داشتند و هر بررسی سازگاری درونی پاس شد. کدگذاری خودسازگار و غلط بود، یعنی همان دستهٔ باگی که هیچ مقدار مقایسهٔ یک ساختار با خودش رو نمی‌کند، و همین درس با ساختاری متفاوت در CMS signedAttrs و مرتب‌سازی DER SET OF گفته شده، جایی که یک SET با یک ترتیب هش و با ترتیب دیگری امیت می‌شد و بی‌اشکال به نظر می‌رسید تا وقتی یک verifier خارجی هش را از نو حساب کرد

آن‌چه این دستهٔ باگ را می‌گیرد یک decoder است که نویسندهٔ encoder نوشته باشد نه — یکی که روی خروجی واقعی backend واقعی اجرا شود. مسیر تأیید در Windows داخل کامپوننت PDFium از CryptoAPI می‌گذرد نه از reader خود کتابخانه، و یک امضای PSS ردشده آن‌جا همان چیزی بود که به پارامترها برگشت. هر ASN.1ای که یک پیاده‌سازی برای خواندن دیگران بیرون می‌دهد حداقل یک رفت‌وبرگشت از decoderی را می‌ارزد که خودش کنترلش نمی‌کند، و هر چه ساختار پیش‌فرض‌ها و تگ‌های بیشتری داشته باشد، آن رفت‌وبرگشت ارزش بیشتری دارد

این کجا در بقیهٔ ماجرای PSS جا می‌گیرد

این fix مستقل از آن دو جای دیگری است که PSS می‌تواند در یک امضای PAdES غلط شود، و جدا نگه داشتنشان عیب‌یابی را کوتاه می‌کند. backend مربوط به macOS می‌تواند بفهمد که یک کلید خاص یا یک سیستم قدیمی‌تر PSS را رد می‌کند و به PKCS#1 v1.5 تنزل بدهد، و AlgorithmIdentifier باید از آن تنزل پیروی کند؛ این یک سؤال قابلیت است که در امضای PAdES با یک هویت Keychain در macOS پوشش داده شده. backend مربوط به PKCS#11 می‌تواند به توکن یک CK_RSA_PKCS_PSS_PARAMS بدهد که توکن چیدمانش را به دلیل ناسازگاری عرض عدد صحیح متفاوت می‌خواند؛ این یک سؤال ABI است که در CK_ULONG و تلهٔ packing در PKCS#11 پوشش داده شده. این مقاله دربارهٔ شکست سوم است، جایی که کلید حاضر بود، توکن بایت‌های درست را حساب کرد، و DERی که نتیجه را توصیف می‌کرد با RFC 4055 §3.1 نمی‌خواند

امضاکننده‌ای که PSS اعلام می‌کند تعهدی برمی‌گرداند که امضاکنندهٔ v1.5 هرگز نداشت: این‌که پارامترهای خودش را به شکلی توصیف کند که پیاده‌سازی دیگری همان سه مقدار را از آن decode کند. RFC 4055 §3.1 تگ‌ها را ثابت می‌کند، X.690 §11.5 تعیین می‌کند کدام فیلدها می‌توانند بیایند، و ETSI TS 119 312 §7 مقدارهایی را ثابت می‌کند که ارزش انتخاب دارند. هر سه backend کامپوننت PDFium Delphi به‌صورت سورس منتشر می‌شوند، پس همان بدنهٔ GetSignatureAlgorithmParams بالا همانی است که می‌توانی بخوانی، dump کنی و با verifier خودت مقایسه کنی، به‌جای این‌که روی اعتماد قبولش کنی