مقاله فنی

ML-DSA در Delphi: پساکوانتومی FIPS 204 در PDFlibPas

PDFlibPas الگوریتم امضای دیجیتال ML-DSA یعنی Module-Lattice-Based Digital Signature Algorithm استانداردشده در FIPS 204 را به‌طور کامل در Object Pascal پیاده‌سازی می‌کند. هر سه مجموعه پارامتر به‌شکل توابع معمولی ارائه می‌شوند: MLDSA44Sign، MLDSA65Sign، MLDSA87Sign به‌علاوه مدخل‌های KeyGen و Verify متناظر. نه OpenSSL، نه DLL پلتفرمی، نه چسب C. تک واحد PDFlibMLDSA به چیزی جز اسفنج SHAKE کتابخانه وابسته نیست و خروجی‌اش با بردارهای known-answer رسمی FIPS 204 بایت به بایت مطابقت دارد

همان جمله آخر تنها بخشی است که کار واقعی برد. نوشتن حساب لاتیس در Pascal کاری مکانیکی است؛ رسیدن به هم‌خوانی با NIST چنین نیست. آن‌چه در ادامه می‌آید روایت مهندسیِ این پورت است: چگونه سه مجموعه پارامتر به یک موتور مشترک رسیدند، و نقص‌های مشخصی که فاصله میان کامپایل می‌شود و اجرا می‌شود تا با KAT مطابقت دارد را پر کردند. اگر برای خط لوله سند Delphi یا C++Builder گزینه‌های پساکوانتومی را ارزیابی می‌کنید، نقص‌ها بخش مفید ماجرا هستند، چون هر کدام خروجی‌ای ظاهراً معقول تولید می‌کند که سازگاری را بی‌صدا شکست می‌دهد

چرا امضاکننده پساکوانتومی را در Object Pascal خالص می‌نویسیم؟

زیرا جایگزینش یک وابستگی بومی به ازای هر مقصد است، و یک کتابخانه PDF برای Delphi از این‌ها به اندازه کافی دارد. PDFlibPas روی Delphi، C++Builder و FPC/Lazarus در مقصدهای Win32، Win64 و Unix بیلد می‌شود؛ اتصال به یک کتابخانه C پساکوانتومی یعنی دنبال کردن بیلد آن برای تک‌تک این جایگاه‌ها، به‌علاوه سطح قرارداد فراخوانی و مالکیت حافظه میانشان. یک واحد Pascal خالص هر جا که بقیه کتابخانه کامپایل می‌شود کامپایل می‌شود، و کل استدلال همین است

ML-DSA این را غیرعادی ارزان می‌کند، چون تنها وابستگی پیش‌فرضش SHAKE است. نه لایه اعداد صحیح بزرگ، نه منحنی بیضوی، نه مجموعه هش جداگانه. PDFlibPas در انتشار بلافاصله قبل از پورت یک XOF استریمی گرفت: TPLShakeXOF در PDFlibDigest، جایی که PLShakeXOFInit گزینش می‌کند SHAKE128 (rate 168) یا SHAKE256 (rate 136)، پس از آن PLShakeXOFAbsorb، PLShakeXOFFinalize و یک حلقه PLShakeXOFSqueeze که برای طول خروجی دلخواه به جای‌گشایی ادامه می‌دهد. هر روتین نمونه‌گیری پس‌زدنی در واحد ML-DSA مستقیماً روی همان API چهار فراخوانی نوشته شده

یک موتور، سه مجموعه پارامتر: TMLDSAParams

PDFlibPas یک مجموعه پارامتر کامل ML-DSA را با یک رکورد توصیف و با شماره مجموعه انتخابش می‌کند، پس ML-DSA-44، 65 و 87 از مسیرهای کد یکسانی عبور می‌کنند. اولین پیاده‌سازی کارا یک بیلد ثابت 4x4 سیم‌کشی‌شده برای ML-DSA-44 بود؛ عمومی‌سازی آن یعنی بردن k و l، eta، tau، beta، gamma1 و gamma2، omega و طول چالش به داخل TMLDSAParams و سپس مشتق کردن بقیه چیزها. مدخل‌های عمومی به wrapperهای سه‌خطی تبدیل شدند

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. نوشته‌شده در Pascal با ایندکس‌گذاری یک‌مبنای AnsiString آن دو بایت Msg[33] و Msg[34] می‌شوند. پیش‌نویس اول این پورت آن‌ها را در Msg[34] و Msg[35] نوشت، دقیقاً یک بایت جابه‌جا، و نتیجه یک جفت‌کلید بود که rho‌اش با بردار تست کاملاً مطابقت داشت در حالی که هر ضریب t غلط بود. فقط ماتریس آلوده شده بود، و ماتریس همان چیزی است که کلید عمومی عیناً حمل نمی‌کند

دو نقص دیگر در همان روتین زندگی می‌کردند. طول absorb باید 34 باشد نه 35؛ یک بایت زباله اضافی کل جریان فشرده‌شده را عوض می‌کند. و حلقه پس‌زدن داخلی باید هر گروه سه‌بایتی‌ای را که بلوک می‌تواند عرضه کند مصرف کند، از جمله گروهی که از آفست 165 بلوک 168 بایتی SHAKE128 شروع می‌شود؛ یعنی 56 گروه به ازای هر بلوک. یک اسکریپت cross-check که در آفست 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 با بردارهای known-answer رسمی FIPS 204 مطابقت پیدا کرد. یک درس دیباگ هم ارزش نام بردن دارد، چون یک نشست خرج برد: وقتی برای یک حلقه پس‌زدن XOF-ران یک cross-check پایتونی می‌سازید، hashlib.shake_128().digest(n) در هر فراخوان همان پیشوند را برمی‌گرداند به‌جای ادامه دادن جریان. کل طول را یک بار بگیرید و بعد به بلوک‌های هم‌اندازه rate برش بزنید، وگرنه مرجع شما با خوشحقی همان مقادیری را دوباره مصرف می‌کند که Pascal شما درست پس زده

روتین expand_a در ML-DSA در PDFlibPas با بافر 34 بایتی شامل rho، ایندکس ستون و ایندکس ردیف SHAKE128 را بذر می‌کند، در کنار پیش‌نویسی که هر دو بایت ایندکس را یک جایگاه دیرتر نوشت و cross-check‌ای که آخرین گروه سه‌بایتی هر بلوک را انداخت
دو نقص off-by-one در یک روتین: بایت‌های ایندکس یک جایگاه دیر نوشته شدند و حلقه پس‌زدنی که پیش از گروه سه‌بایتی در آفست 165 توقف کرد

نمونه‌گیری eta: چرا ML-DSA-65 به شاخه خودش نیاز دارد

PDFlibPas در expand_s دو مسیر جدا نگه می‌دارد چون الگوریتم 33 FIPS 204 واقعاً دو تا تعریف می‌کند. برای eta = 2 هر نیبل وقتی به 15 برسد پس زده می‌شود و در غیر این صورت mod 5 می‌شود. برای eta = 4 نیبل در 9 یا بالاتر پس زده می‌شود و بعد مستقیماً استفاده می‌شود، بدون هیچ کاهش پیمانه‌ای. ML-DSA-65 تنها مجموعه منتشرشده با eta = 4 است و استفاده مجدد از مسیر mod 5 برایش s1 و s2 را از نخستین ضریب ناهم‌راستا می‌کند؛ نتیجه یک جفت‌کلید است که درونی‌اش سازگار، روی خودش verify می‌شود و با هیچ چیز تولیدشده توسط دیگران مطابقت ندارد

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، بعد کاهش mod 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 را به‌جای 3309 استاندارد، 3293 بایت می‌کند و پیشوند KAT بلافاصله واگرا می‌شود. فیلد رکورد CTildaBytes دقیقاً برای همین وجود دارد که این عدد فراموش نشود

دومی پهنای بسته‌بندی چندجمله‌ای ماسک z است. PDFlibPas آن را به‌شکل BitWidth(Gamma1) حساب می‌کند نه به‌شکل توان: gamma1 = 2^19 برای ML-DSA-65 و 87 به 20 بیت به ازای هر ضریب نیاز دارد نه 19، و همان تک بیت تعیین می‌کند که هر چندجمله‌ای z روی هم 640 بایت اشغال می‌کند یا چیزی که هیچ verifier‌ای تجزیه‌اش نخواهد کرد. Verifier در طول پورت نقص متناظری داشت: بافر deserialization z به‌جای 576 بایت، 192 بایت اندازه گرفته شده بود. طول امضا ارزان‌ترین تست رگرسیونی‌ای است که تا به حال نوشته‌اید: 2420، 3309 و 4627 را روی Length(Signature) assert کنید و بیشتر اشتباهات پارامتری خودشان را پیش از رسیدن به حتی یک ادعای رمزنگاشتی اعلام می‌کنند

PDFlibPas دو پارامتر کدگذاری ML-DSA را به سطح امنیت گره می‌زند: هش چالش c-tilde از 32 به 48 و 64 بایت رشد می‌کند و چندجمله‌ای ماسک z با پهنای بیتی gamma1 بسته‌بندی می‌شود، با طول امضا به‌عنوان تست رگرسیونی
دو پارامتر با سطح امنیت تغییر می‌کنند و امضایی که به‌جای 3309 بایت 3293 بایت در می‌آید پیش از اجرای هر ادعای رمزنگاشتی اشتباه را اعلام می‌کند

امضا بدون حلقه بی‌کران

امضای ML-DSA پس‌زنی است، پس با kappa افزایش‌یافته تلاش مجدد می‌کند تا امضای کاندید آزمون‌های نُرم و hint خودش را پاس کند. PDFlibPas این را با بودجه بیرونی صریح 65535 تلاش سقف می‌زند؛ در اتمام بودجه MLDSASignInternal مقدار False برمی‌گرداند و امضا را خالی می‌گذارد به‌جای اینکه داخل یک نخ تولید سند بچرخد. در عمل بردار رسمی ML-DSA-44 در kappa = 4 با 55 hint در برابر سقف omega یعنی 80 موفق می‌شود، پس بودجه یک نرده ایمنی است نه یک محدودیت کارا

باگی که آن نرده را ضروری حس کرد اصلاً عددی نبود. امضا گویا هنگ می‌کرد، شک افتاد به decompose و make_hint (الگوریتم‌های 36 و 39 FIPS 204)، و علت واقعی یک هدف تجمع معکوس بود: برداری که به محاسبه hint می‌خورد باید c*t0 را تجمع کند، در حالی که c*t0 اصلی باید دست‌نخورده برای آزمون نُرم باقی بماند. هر دو را به یک بافر نشانه بگیرید و حلقه برای همیشه پس می‌زند با حساب کاملاً درست. روی هر دو مسیر موفقیت و اتمام بودجه، واحد بذرهای مشتق‌شده، چندجمله‌ای‌های مخفی، ماسک‌ها، چالش و بافرهای کدگذاری را صفر می‌کند؛ بذر، کلید خصوصی و rnd داده‌شده توسط فراخوان مسئولیت خود فراخوان می‌ماند که تقسیم درستی است برای کتابخانه‌ای که نمی‌داند آن رشته‌ها از کجا آمده‌اند

ML-DSA امروز کجا با پشته امضای PDF تلاقی می‌کند؟

درباره چیزی که وجود دارد دقیق باشید. PDFlibPas الگوریتم ML-DSA را به‌شکل پیش‌فرض‌های امضای راستی‌آزمایی‌شده به‌علاوه یک اتصال مکانیزم PKCS #11 منتشر می‌کند، نه به‌شکل جایگزین drop-in برای خروجی 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 و یکپارچه‌سازی توکن در کنارش. برای آزمایش‌های محلی همان گردش کار گواهی self-signed بنا شده روی CryptoAPI بدون دخالت یک CA عمومی به شما هویت امضا می‌دهد

تغییر مجموعه پارامتر را همان‌طور تست کنید که هر تغییر امضای دیگری را تست می‌کنید. اول اندازه‌ها، بعد بردارهای رسمی، بعد حالت‌های منفی: یک بایت امضای دستکاری‌شده، یک رشته context ناهم‌خوان، یک کلید ناقص. PDFlibPas همه این‌ها را در مجموعه DUnitX خودش پوشش می‌دهد و همین نظم جای خودش در خط لوله شماست، ترجیحاً در کنار میز کار انطباق و امضا که اعتبارسنجی را روی یک پیکره سند دسته‌بندی می‌کند تا یک رگرسیون هرگز بی‌خبر به مشتری نرسد

آمادگی پساکوانتومی برای نرم‌افزار سند به‌شکل یک کلید واحد نمی‌رسد. به‌شکل پیش‌فرض‌هایی می‌رسد که می‌توانید تست‌شان کنید، یک مسیر توکن که می‌توانید وصلش کنید، و یک مسیر استاندارد که دنبالش می‌کنید بدون اینکه انتشار فعلی را روی آن شرط‌بندی کنید. برای دیدن اینکه واحد ML-DSA در کنار بقیه ابزار امضا، رمزنگاری و PDF/A در یک کدبیس بومی Object Pascal چطور می‌نشیند، صفحه محصول PDFlibPas Delphi PDF library مجموعه کامل کامپوننت‌ها و ماتریس کامپایلرهای پشتیبانی‌شده را فهرست می‌کند