מאמר טכני

ML-DSA ב־Delphi: פוסט־קוונטום מתוך FIPS 204 ב־PDFlibPas

PDFlibPas מממש את ML-DSA, אלגוריתם החתימה הדיגיטלי המבוסס סריגים מודולריים שתוקנן ב־FIPS 204, כולו ב־Object Pascal. שלוש קבוצות הפרמטרים מגיעות כפונקציות רגילות: MLDSA44Sign, MLDSA65Sign, MLDSA87Sign, ולצדן כניסות KeyGen ו־Verify מתאימות. בלי OpenSSL, בלי DLL של הפלטפורמה, בלי דבק C. יחידה אחת בשם PDFlibMLDSA תלויה רק בספוג SHAKE של הספרייה, והפלט שלה תואם בייט אחר בייט את וקטורי בדיקת הידיעה הרשמיים של FIPS 204

המשפט האחרון הזה הוא החלק היחיד שדרש עבודה אמיתית. כתיבת אריתמטיקת סריגים בפסקל היא מכנית; לגרום לה להסכים עם NIST זה כבר סיפור אחר. מה שבא הוא תיאור ההנדסה של הפורט: איך שלוש קבוצות הפרמטרים הגיעו לשתף מנוע אחד, והפגמים הספציפיים שהפרידו בין מתקמפל ורץ לבין תואם את ה־KAT. אם אתם מעריכים אפשרויות פוסט־קוונטום לצינור מסמכים ב־Delphi או C++Builder, הפגמים הם החלק השימושי, כי כל אחד מהם מייצר פלט שנראה סביר ונכשל בשקט בתאימות

למה לכתוב חותם פוסט־קוונטום ב־Object Pascal טהור?

כי החלופה היא תלות נייטיב אחת לכל יעד, ולספריית PDF ל־Delphi כבר יש מספיק כאלה. PDFlibPas נבנה על פני Delphi, C++Builder ו־FPC/Lazarus ביעדי Win32, Win64 ו־Unix; קישור לספריית פוסט־קוונטום ב־C פירושו לעקוב אחרי בנייה שלה עבור כל אחד מהמשבצות האלה, ובנוסף את משטח הקריאות והבעלות על הזיכרון ביניהן. יחידת פסקל טהורה מתקמפלת בכל מקום ששאר הספרייה מתקמפלת בו, וזו כל הטיעון

ML-DSA הופך זאת לזול באופן חריג, כי התלות הפרימיטיבית היחידה שלו היא SHAKE. אין שכבת מספרים שלמים גדולים, אין עקומה אליפטית, אין חבילת גיבוב נפרדת. PDFlibPas קיבל XOF זורם במהדורה שקדמה מיד לפורט: TPLShakeXOF ב־PDFlibDigest, שבה PLShakeXOFInit בוחר SHAKE128 (rate 168) או SHAKE256 (rate 136), ואחריו PLShakeXOFAbsorb, PLShakeXOFFinalize ולולאת PLShakeXOFSqueeze שממשיכה לערבב עבור אורך פלט שרירותי. כל שגרת דגימת דחייה ביחידת ה־ML-DSA נכתבה ישירות מול ממשק ארבע הקריאות הזה

מנוע אחד, שלוש קבוצות פרמטרים: TMLDSAParams

PDFlibPas מתאר קבוצת פרמטרים שלמה של ML-DSA ברשומה אחת ובוחר אותה לפי מספר הקבוצה, כך ש־ML-DSA-44, 65 ו־87 עוברים באותם נתיבי קוד. המימוש הראשון שעבד היה בנייה קבועה של 4x4 שחוטבלה ל־ML-DSA-44; הכללתה פירושה הרמת k ו־l, eta, tau, beta, gamma1 ו־gamma2, omega ואורך האתגר אל תוך TMLDSAParams, ואז גזירת כל השאר. נקודות הכניסה הציבוריות הפכו למעטפות של שלוש שורות

Type
  TMLDSAParams= Record
    K, L, D, Eta, Tau, Beta, Gamma1, Gamma2, Omega: Integer;
    Alpha, MW1: Cardinal;
    W1BW, EtaBW, Gamma1BW, T1BW: Integer;
    T0Rng: Cardinal;
    CTildaBytes: Integer;
    PublicKeyBytes, SecretKeyBytes, SignatureBytes: Integer;
  End;

// שדות נגזרים מחושבים, לעולם לא מועתקים מטבלה
Params.Alpha:= 2* Cardinal(Params.Gamma2);
Params.MW1:= (Q- 1)div Params.Alpha;
Params.W1BW:= BitWidth(Params.MW1- 1);
Params.EtaBW:= BitWidth(2* Cardinal(Params.Eta));
Params.Gamma1BW:= BitWidth(Cardinal(Params.Gamma1));

Function MLDSA65Sign(Const SecretKey, Message, Context, Rnd: AnsiString;
  Out Signature: AnsiString): Boolean;
Var
  Params: TMLDSAParams;
Begin
  BuildMLDSAParams(65, Params);
  Result:= MLDSASignInternal(Params, SecretKey, Message, Context, Rnd,
    Signature);
End;

חמשת השדות הנגזרים מחושבים במכוון ולא מועתקים מהטבלאות של FIPS 204. רוחבי סיביות שהועתקו ביד הם בדיוק סוג הקבוע שנראה נכון בסקירה וטועה באחד בפרודקשן, ושניים מהפגמים האמיתיים בפורט הזה היו מהצורה הזו. הגדלים המוצהרים נשארים קבועים בעלי שם לצורך ולידציה: 1312 / 2560 / 2420 בייטים למפתח ציבורי, מפתח סודי וחתימה עבור ML-DSA-44, 1952 / 4032 / 3309 עבור ML-DSA-65, 2592 / 4896 / 4627 עבור ML-DSA-87

PDFlibPas מנתב את MLDSA44Sign, MLDSA65Sign ו־MLDSA87Sign דרך BuildMLDSAParams אל רשומת TMLDSAParams אחת ששדותיה הנגזרים מחושבים ולא מועתקים, כך שמנוע MLDSASignInternal משותף אחד משרת את שלוש קבוצות הפרמטרים של FIPS 204
שלוש קבוצות פרמטרים חולקות מנוע אחד כי מספר הקבוצה רק בוחר רשומה, ורוחבי הסיביות הנגזרים מחושבים במקום להיות מועתקים מהטבלאות של FIPS 204

איפה פורט ML-DSA מאפס נכשל קודם?

ב־expand_a, אלגוריתם 32 של FIPS 204, ומצב הכשל מטעה בצורה יפהפייה. המטריצה A נדגמת על ידי הזרעת SHAKE128 עם rho ואחריו שני בייטי אינדקס, ולכן חוצץ הזרע הוא 34 בייטים: rho(32), ואז j, ואז i. נכתב בפסקל עם אינדוקס 1־מבוסס של AnsiString שני הבייטים הם Msg[33] ו־Msg[34]. הטיוטה הראשונה של הפורט כתבה אותם ל־Msg[34] ו־Msg[35], הוזה בבייט אחד בדיוק, והתוצאה הייתה זוג מפתחות שה־rho שלו תאם את וקטור הבדיקה להפליא בזמן שכל מקדם של t היה שגוי. רק המטריצה זוהמה, והמטריצה היא דווקא הדבר היחיד שהמפתח הציבורי אינו נושא כמות שהוא

שני פגמים נוספים התגוררו באותה שגרה. אורך ה־absorb חייב להיות 34 ולא 35; בייט זבל אחד נוסף משנה את כל הזרם הנסחט. ולולאת הדחייה הפנימית חייבת לצרוך כל קבוצה בת שלושה בייטים שהבלוק מספק, כולל זו שמתחילה בהיסט 165 של בלוק SHAKE128 בן 168 בייטים, שהן 56 קבוצות לבלוק. סקריפט צליבה שעצר בהיסט 162 השמיט את הזנב של כל בלוק והזיז את קידומת ה־t1 המדוגמת מהבייט השלושה־עשר בערך והלאה

SetLength(Msg, 34);
Move(Rho[1], Msg[1], 32);
Msg[33]:= AnsiChar(J);          // אינדקס העמודה קודם
Msg[34]:= AnsiChar(I);          // ואז אינדקס השורה
PLShakeXOFInit(Ctx, True);      // SHAKE128, rate 168
PLShakeXOFAbsorb(Ctx, @Msg[1], 34);
PLShakeXOFFinalize(Ctx);
Cnt:= 0;
While Cnt< N Do
Begin
  PLShakeXOFSqueeze(Ctx, @Buf[0], 168);
  BOff:= 0;
  // BOff+2 <= 167 שומר על הקבוצה בהיסט 165: 56 שלשות לבלוק
  While (BOff+ 2<= High(Buf))And (Cnt< N) Do
  Begin
    T3:= ((Buf[BOff+ 2]and $7F)shl 16)xor (Buf[BOff+ 1]shl 8)xor Buf[BOff];
    If T3< Q Then
    Begin
      Poly^[Cnt]:= T3;
      Inc(Cnt);
    End;
    Inc(BOff, 3);
  End;
End;

עם שלוש התיקונים האלה, תקצירי ה־SHA-256 של המפתחות הציבורי והסודי המלאים של ML-DSA-44 תאמו את וקטורי ה־KAT של FIPS 204. שיעור דיבוג אחד שווה גם לנסות בשם, כי הוא עלה בסשן: כשבונים בדיקת צליבה בפייתון ללולאת דחייה מונעת XOF, hashlib.shake_128().digest(n) מחזיר את אותה קידומת בכל קריאה במקום להמשיך את הזרם. קחו את האורך המלא פעם אחת ואז חתכו אותו לבלוקים בגודל rate, אחרת הרפרנס שלכם יצרוך ברצון את הערכים הממש שהפסקל שלכם דחה נכון

שגרת ה־expand_a של ML-DSA ב־PDFlibPas מזריעה SHAKE128 עם חוצץ בן 34 בייטים שמכיל את rho, אינדקס העמודה ואינדקס השורה, לצד טיוטה ראשונה שהזיזה את שני בייטי האינדקס ובדיקת צליבה שהשמיטה את קבוצת שלושת הבייטים האחרונה של כל בלוק
שני פגמי סטייה־באחד באותה שגרה: בייטי אינדקס שנכתבו במקום אחד מאוחר מדי, ולולאת דחייה שעוצרת לפני קבוצת שלושת הבייטים בהיסט 165

דגימת eta: למה ML-DSA-65 צריך ענף משלו

PDFlibPas משאיר שני נתיבים נפרדים ב־expand_s כי אלגוריתם 33 של FIPS 204 מגדיר באמת שניים. עבור eta = 2 כל nibble נדחה כשהוא מגיע ל־15 ואחרת מצומצם מודולו 5. עבור eta = 4 ה־nibble נדחה מ־9 ומעלה ואז נלקח כמו שהוא, בלי צמצום מודולרי בכלל. ML-DSA-65 היא קבוצת הפרמטרים היחידה שנשלחת עם eta = 4, ושימוש חוזר בנתיב המודולו 5 עבורה מיישר את s1 ו־s2 שלא כהלכה כבר מהמקדם הראשון, ומייצר זוג מפתחות שעקבי פנימית, מאמת מול עצמו, ולא תואם לשום דבר שמישהו אחר מייצר

Procedure StoreNibble(Nibble: Byte);
Var
  M: Integer;
  Centered: Cardinal;
Begin
  If Cnt>= N Then
    Exit;
  If Eta= 4 Then
  Begin
    If Nibble>= 9 Then        // לדחות, ואז לקחת את הניבל כמו שהוא
      Exit;
    M:= Nibble;
  End
  Else
  Begin
    If Nibble>= 15 Then       // eta = 2: לדחות 15, ואז לצמצם מודולו 5
      Exit;
    M:= Nibble mod 5;
  End;
  If Eta>= M Then
    Centered:= Eta- M
  Else
    Centered:= Q- (M- Eta);
  Vec[I][Cnt]:= Centered;
  Inc(Cnt);
End;

הגדלים הם הבדיקה: אורך ה־c-tilde ורוחב הסיביות של gamma1

שני פרמטרי קידוד משתנים עם רמת האבטחה בדרכים שקל לפספס כשבניית ML-DSA-44 עובדת מונחת שם. גיבוב האתגר c-tilde הוא בגודל 2 x lambda / 8 בייטים, כלומר 32 עבור ML-DSA-44, 48 עבור ML-DSA-65 ו־64 עבור ML-DSA-87. השארה שלו קבועה על 32 מניבה חתימת ML-DSA-65 של 3293 בייטים במקום 3309 התקניים, וקידומת ה־KAT סוטה מיד. שדה הרשומה CTildaBytes קיים בדיוק כדי שהמספר הזה לא יישכח

השני הוא רוחב האריזה של פולינום המסכה z. PDFlibPas מחשב אותו כ־BitWidth(Gamma1) ולא כמעריך: gamma1 = 2^19 עבור ML-DSA-65 ו־87 דורש 20 סיביות למקדם ולא 19, והסיבית הבודדת הזו מכריעה אם כל פולינום z תופס 640 בייטים או משהו שאף מאמת לא יפרק. המאמת נשא פגם תואם במהלך הפורט, שבו חוצץ פירוק ה־z מודד 192 בייטים במקום 576. אורך חתימה הוא בדיקת הרגרסיה הזולה ביותר שתכתבו אי פעם: טענו 2420, 3309 ו־4627 מול Length(Signature) ורוב טעויות הפרמטור יזדהו עוד לפני שתגיעו לטענה קריפטוגרפית אחת

PDFlibPas קושר שני פרמטרי קידוד של ML-DSA לרמת האבטחה: גיבוב האתגר c-tilde גדל מ־32 ל־48 ול־64 בייטים, ופולינום המסכה z נארז ברוחב BitWidth של סיביות gamma1, עם אורך החתימה כבדיקת רגרסיה
שני פרמטרים משתנים עם רמת האבטחה, וחתימה שיוצאת בת 3293 בייטים במקום 3309 מכריזה על הטעות עוד לפני שרצה טענה קריפטוגרפית כלשהי

חתימה בלי לולאה ללא חסם

חתימת ML-DSA מבוססת דחייה, ולכן היא חוזרת עם kappa מוגדל עד שחתימת מועמדת עוברת את בדיקות הנורמה והרמז שלה. PDFlibPas חוסם זאת בתקציב חיצוני מפורש של 65535 ניסיונות; באזילתו MLDSASignInternal מחזיר False ומשאיר את החתימה ריקה במקום להסתחרר בתוך תהליך ייצור מסמכים. בפועל הווקטור הרשמי של ML-DSA-44 מצליח ב־kappa = 4 עם 55 רמזים מול תקרת ה־omega של 80, ולכן התקציב הוא מסילת בטיחות ולא מגבלה עובדת

הבאג שגרם למסילה הזו להרגיש הכרחית לא היה מספרי כלל. החתימה נראתה נתקעת, החשד נפל על decompose ועל make_hint (אלגוריתמים 36 ו־39 של FIPS 204), והסיבה האמיתית הייתה יעד צבירה הפוך: הווקטור שמזין את חישוב הרמז חייב לצבור c*t0, בזמן שה־c*t0 המקורי חייב לשרוד ללא פגע עבור בדיקת הנורמה. כוונו את שניהם אל אותו חוצץ והלולאה תדחה לנצח עם אריתמטיקה נכונה להפליא. בשני הנתיבים, המוצלח וזה שאוזל התקציב, היחידה מאפסת זרעים נגזרים, פולינומים סודיים, מסכות, את האתגר ואת חוצצי הקידוד; הזרע, מפתח הסודי וה־rnd שסיפק הקורא נשארים באחריות הקורא, וזו החלוקה הנכונה לספרייה שאינה יכולה לדעת מאין המחרוזות האלה הגיעו

איפה ML-DSA פוגש את מחסנית החתימות של PDF כיום?

מדויקים לגבי מה שקיים. PDFlibPas משלח ML-DSA כפרימיטיבי חתימה מאומתים ועוד קישור למנגנון PKCS #11, ולא כתחליף ישיר לפלט ה־PAdES הנוכחי שלכם. נתיב הטוקן הוא TPDFlibPKCS11Client.SignMLDSA, והוא במכוון נקודת כניסה נפרדת כי CKM_ML_DSA צורך את ההודעה הגולמית ולא תקציר מחושב מראש, ולכן את הקריאות החוזרות הקיימות של SignHash ושל תקציר חיצוני אי אפשר לעשות בהן שימוש חוזר. גילוי ללא תעודה מחייב הפעלה מפורשת של CertificateOptional יחד עם תווית או מזהה של מפתח פרטי, והלקוח מאמת את CKA_PARAMETER_SET מול רשימת ההיתרים CKP_ML_DSA_44 / 65 / 87 בזמן ההתחברות, כך שזיווג ברירת המחדל של תעודות RSA ו־ECDSA לעולם לא מתרופף בטעות

שילוב ברמת המסמך הוא החלק שעדיין נשלט על ידי עבודת תקינה ולא על ידי קוד הספרייה. ISO 32000-2 §12.8 מגדיר את מילון החתימה ואת מטען ה־CMS שלו, ו־ISO/TS 32002 הוא כלי הרכב להרחבת התמיכה לאלגוריתמי גיבוב וחתימה חדשים; עד שהמאמתים והצדדים שלכם ילכו בעקבות, חתימה קלאסית נשארת נתיב הפרודקשן. העמדה המעשית היא מסילות מקבילות: ממשיכים לשלוח חתימות PAdES מ־B-B ועד B-LTA עם חותמת זמן ונתוני ולידציה ארוכת טווח לכל דבר שצד שלישי חייב לאמת היום, תוך הוכחת טיפול במפתחות ML-DSA ושילוב טוקנים לצידן. לניסויים מקומיים, אותו תהליך תעודה חתומה־עצמית הבנוי על CryptoAPI נותן לכם זהות חתימה בלי לערב CA ציבורי

בודקים שינוי קבוצת פרמטרים כפי שהייתם בודקים כל שינוי חתימה אחר. קודם גדלים, אחר כך הווקטורים הרשמיים, ואז המקרים השליליים: בייט חתימה ששונה, מחרוזת הקשר שאינה תואמת, מפתח קטוע. PDFlibPas מכסה את כולם בחבילת ה־DUnitX שלו, והמשמעת הזו שייכת גם לצינור שלכם, במיטבה לצד סדנת התאימות והחתימה שמריצה ולידציה על פני קורפוס מסמכים כך שרגרסיה לעולם לא מגיעה ללקוח בלי ששמתם לב

מוכנות פוסט־קוונטום לתוכנת מסמכים לא תגיע כמתג אחד. היא מגיעה כפרימיטיבים שאפשר לבדוק, נתיב טוקן שאפשר לחבר, ומסילת תקינה שעוקבים אחריה בלי להמר על המהדורה הנוכחית. כדי לראות איך יחידת ה־ML-DSA יושבת לצד שאר כלי החתימה, ההצפנה וה־PDF/A בקודבייס Object Pascal נייטיב, עמוד המוצר של PDFlibPas Delphi PDF library מפרט את ערכת הרכיבים המלאה ואת מטריצת הקומפיילרים הנתמכים