מאמר טכני

קידוד RSASSA-PSS-params לפי RFC 4055 ב-PDFium לדלפי

‏רכיב PDFium גרסה 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 מקבל פרמטרים של פונקציית hash, פונקציית יצירת מסכה עם hash משל עצמה, ואורך מלח, ו-RFC 8017 §A.2.3 משאיר את שלושתם פתוחים. החותם בוחר אותם, ה-AlgorithmIdentifier נושא אותם, והמאמת חייב לשחזר אותם בדיוק לפני ש-EMSA-PSS-VERIFY יכול בכלל להתחיל

אז כשהרכיב חותם עם 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; עם תגים, מספר התג מזהה את השדה בלי קשר לאילו שכנים נמצאים. הערכים שהרכיב פולט הולכים לפי הפרופיל ב-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, ואלגוריתם PSS לחתימת עיכול SHA-256 במסגרת Security

תרשים ברכיב PDFium של RSASSA-PSS-params לפי RFC 4055: hashAlgorithm‏ A0, maskGenAlgorithm‏ A1 ו-saltLength‏ A2 נושאים תגי הקשר מפורשים עם ערכי DEFAULT, הפרופיל של ETSI פולט SHA-256, MGF1 עם SHA-256 ומלח 32, ו-trailerField‏ A3 שווה ל-trailerFieldBC כך ש-DER משמיט אותו לגמרי
כל שדה מתויג בדיוק משום שכל שדה אופציונלי דרך ברירת המחדל שלו, כך שמספר התג מזהה את השדה ולא משנה אילו שכנים המקודד משמיט

איך שלושה מנועים עשו את אותה טעות

הקידוד של 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, קורא את פונקציית יצירת המסכה, ואז פוגש 02 01 20. זה INTEGER אוניברסלי, ול-RSASSA-PSS-params אין שום איבר INTEGER בלי תג בשום מקום. מפענח מחמיר נעצר שם. מפענח סלחני מדלג על האלמנט שלא זיהה, לא מוצא A2 בכלל, מקצה ל-saltLength את ברירת המחדל שלו 20, ואז פוגש INTEGER תועה שני, 02 01 01, ונתקל באותה בעיה שוב. אף אחד מהמסלולים לא מגיע למלח בן 32 בתים. תבנית משותפת יעילה כשהיא נכונה ודרך יעילה באותה מידה לטעות שלוש פעמים כשהיא לא, ולכן התיקון יצא בשלוש היחידות בקומיט אחד ולכן שלושת גופי המתודות נשארו זהים מבנית אחריו. מנוע עתידי צריך להעתיק את הבלוק מאחד מהם ולא לגזור אותו מחדש, כי הגזירה היא בדיוק המקום שבו נעשתה הטעות

תרשים ברכיב PDFium של באג ה-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. אילו המנוע היה חותם עם SHA-1 ומלח בן 20 בתים, RFC 4055 §3.1 היה מקפל את הפרמטרים ל-SEQUENCE ריק, 30 00, וה-SEQUENCE הריק הזה ולא NULL הוא מה שמאמת מצפה לו. רכיב PDFium אף פעם לא פולט את הצורה הזו כי הוא אף פעם לא חותם עם הערכים האלה, אבל זה המקרה שתופס כל מי שמניח ש"בלי פרמטרים" תמיד מאוית 05 00

זו ההבחנה בין DER ל-BER שחשובה לחתימות במיוחד. BER מתיר למקודד לכלול רכיב בעל ערך ברירת מחדל; DER אוסר זאת, כי DER קיים כדי שלערך אחד יהיה קידוד אחד בדיוק, וחתימה מעל מבנה עם שני קידודים חוקיים היא חתימה שאפשר להתווכח עליה. כל מה שבתוך signedAttrs של CMS הוא DER מהסיבה הזו, ובלוק הפרמטרים נוסע בתוך signedAttrs דרך המאפיין cmsAlgorithmProtection וגם ב-signatureAlgorithm החיצוני, כך שהוא לא מקבל פטור

תרשים ברכיב PDFium של כלל X.690 11.5 בתיקון ה-PSS: trailerField ששווה ל-DEFAULT שלו trailerFieldBC חייב להישאר בחוץ כי עטיפת A3 מתויגת היא הקידוד ש-DER מחמיר דוחה, בעוד saltLength‏ 32 שונה מברירת המחדל 20 וחייב להיות נוכח כ-A2
DER קיים כדי שלערך אחד יהיה קידוד אחד בדיוק, ורכיב ששווה לברירת המחדל שלו כבר מחזיק את הקידוד הקצר ביותר שיש, דהיינו לא להופיע בכלל

הקידוד המתוקן ב-TDerWriter

רכיב PDFium בונה כעת את הפרמטרים בשלוש קריאות ל-TDerWriter.ContextSpecific מ-FPdfAsn1.pas, אחת לכל שדה שאינו ברירת מחדל, כל אחת עם הארגומנט Constructed מוגדר ל-True כדי להפיק את עטיפת התג המפורש, ובלי שורה בכלל לשדה ה-trailer. זה הגוף של TWinCmsSigner.GetSignatureAlgorithmParams עם ה-OIDs כתובים במפורש; יחידות 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 ב-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 סביב AlgorithmIdentifier של SHA-256 באורך 15 בתים, A1 1C סביב AlgorithmIdentifier של MGF1 באורך 28 בתים שהפרמטרים שלו הם אותו 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 גם ממקם את AlgorithmIdentifier של החתימה, כולל הפרמטרים, בתוך המאפיין החתום cmsAlgorithmProtection מ-RFC 6211, ומאמת משווה את העותק הזה מול ה-signatureAlgorithm החיצוני. שני העותקים באו מאותה קריאה, כך שהם תאמו באופן מושלם וכל בדיקת עקביות פנימית עברה. הקידוד היה עקבי לעצמו ושגוי, וזו קטגוריית הבאגים ששום השוואה של מבנה לעצמו לא יכולה לחשוף, ואותו לקח עם מבנה אחר מסופר ב‏CMS signedAttrs ומיון DER SET OF, שם SET שעוכל בסדר אחד ונפלט בסדר אחר נראה תקין עד שמאמת זר חישב מחדש את ה-hash

מה שכן תופס סוג כזה של באג הוא מפענח שלא נכתב על ידי מחבר המקודד, שרץ מול הפלט האמיתי של המנוע האמיתי. מסלול האימות ב-Windows ברכיב PDFium עובר דרך CryptoAPI ולא דרך הקורא של הספרייה עצמה, וחתימת PSS שנדחתה שם היא מה שהוביל חזרה לפרמטרים. כל ASN.1 שמימוש פולט כדי שמימושים אחרים יקראו אותו ראוי לפחות להלוך ושוב אחד דרך מפענח שאינו בשליטתו, וככל שלמבנה יש יותר ברירות מחדל ותגים, כך אותו הלוך ושוב שווה יותר

איפה זה משתלב עם שאר סיפור ה-PSS

התיקון הזה בלתי תלוי בשני המקומות האחרים שבהם PSS יכול להשתבש בחתימת PAdES, והפרדה ביניהם מקצרת דיבוג. מנוע ה-macOS יכול לגלות שמפתח מסוים או מערכת ישנה יותר מסרבים ל-PSS ולהוריד ל-PKCS#1 v1.5, וה-AlgorithmIdentifier חייב ללכת אחרי ההורדה; זו שאלת יכולת, שמכוסה בחתימת PAdES עם זהות מ-Keychain ב-macOS. מנוע ה-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 מגיעים כקוד מקור, כך שגוף GetSignatureAlgorithmParams שלמעלה הוא זה שאתם יכולים לקרוא, לדמפ ולהלוות מול המאמת שלכם במקום לקבל באמון