บทความเทคนิค

ML-DSA ใน Delphi: FIPS 204 Post-Quantum ใน PDFlibPas

PDFlibPas implement ML-DSA ซึ่งเป็น Module-Lattice-Based Digital Signature Algorithm ตามมาตรฐาน FIPS 204 ล้วนด้วย Object Pascal ทั้งสาม parameter set มาพร้อมเป็นฟังก์ชันธรรมดา: MLDSA44Sign, MLDSA65Sign, MLDSA87Sign บวก KeyGen และ Verify ที่จับคู่กัน ไม่มี OpenSSL ไม่มี DLL ของแพลตฟอร์ม ไม่มีกาว C ยูนิตเดียวชื่อ PDFlibMLDSA พึ่งพาอะไรนอกจาก SHAKE sponge ของไลบรารีเท่านั้น และผลลัพธ์ของมันตรงกับเวกเตอร์ทดสอบ known-answer ของ FIPS 204 อย่างเป็นทางการทีละไบต์

ประโยคสุดท้ายนั่นแหละคือส่วนเดียวที่ใช้แรงงานจริง เขียนเลขคณิต lattice ใน Pascal เป็นงานเชิงกล ๆ ทำให้มันเห็นพ้องกับ NIST ต่างหากที่ไม่ใช่ ต่อจากนี้คือบันทึกฝั่งวิศวกรรมของการพอร์ต: parameter set ทั้งสามจบลงด้วยการใช้เครื่องยนต์ร่วมกันอย่างไร และข้อบกพร่องเฉพาะที่แยก "คอมไพล์ผ่านและรันได้" ออกจาก "ตรงกับ KAT" ถ้ากำลังประเมินทางเลือก post-quantum ให้ไปป์ไลน์เอกสาร Delphi หรือ C++Builder ข้อบกพร่องพวกนี้คือส่วนที่มีประโยชน์ เพราะทุกข้อผลิตเอาต์พุตหน้าตาน่าเชื่อที่ล้ม interoperability เงียบ ๆ

ทำไมต้องเขียน signer post-quantum ล้วน Object Pascal

เพราะทางเลือกคือ native dependency หนึ่งชุดต่อเป้าหมายหนึ่งตัว ซึ่งไลบรารี PDF ของ Delphi มีพอตัวอยู่แล้ว PDFlibPas บิลด์คร่อม Delphi, C++Builder และ FPC/Lazarus บนเป้า Win32, Win64 และ Unix การ bind ไลบรารี post-quantum ภาษา C หมายถึงต้องตามดูบิลด์ของมันทุกช่อง บวกผิว calling convention และความเป็นเจ้าของหน่วยความจำระหว่างกัน ยูนิต Pascal ล้วนคอมไพล์ได้ทุกที่ที่ส่วนที่เหลือของไลบรารีคอมไพล์ได้ และนั่นคือข้อโต้แย้งทั้งหมด

ML-DSA ทำให้เรื่องนี้ถูกผิดปกติ เพราะ primitive dependency เดียวของมันคือ SHAKE ไม่มีชั้น big-integer ไม่มีเส้นโค้งรูปวงรี ไม่มีชุด hash แยก PDFlibPas ได้ streaming XOF มาในรีลีสก่อนการพอร์ตทันที: TPLShakeXOF ใน PDFlibDigest โดย PLShakeXOFInit เลือก SHAKE128 (rate 168) หรือ SHAKE256 (rate 136) ตามด้วย PLShakeXOFAbsorb, PLShakeXOFFinalize และลูป PLShakeXOFSqueeze ที่ไล่ permutation ต่อไปเรื่อย ๆ สำหรับความยาวเอาต์พุตใด ๆ ทุก routine rejection-sampling ในยูนิต ML-DSA เขียนตรงกับ API สี่คำเรียกนั้น

เครื่องยนต์เดียว สาม parameter set: TMLDSAParams

PDFlibPas บรรยาย parameter set ทั้งชุดของ ML-DSA ด้วยเรกคอร์ดเดียวและเลือกด้วยเลข set ทำให้ ML-DSA-44, 65 และ 87 วิ่งผ่านเส้นทางโค้ดเดียวกัน การ implement แรกที่ใช้งานได้เป็นบิลด์ 4x4 ตายตัวสำหรับ ML-DSA-44 การทั่วไปมันหมายถึงการยก k กับ l, eta, tau, beta, gamma1 กับ gamma2, omega และความยาว challenge เข้า TMLDSAParams แล้ว derive สิ่งอื่นทั้งหมดตาม จุดเข้าสาธารณะก็เหลือเป็น 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;

// ฟิลด์ที่ derive ถูกคำนวณ ไม่ใช่คัดลอกจากตาราง
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;

ฟิลด์ที่ derive ทั้งห้าถูกคำนวณแทนการคัดลอกจากตาราง FIPS 204 โดยตั้งใจ ความกว้างบิตที่ถอดด้วยมือเป็นชนิดค่าคงที่พอดีที่ดูถูกใน review แต่เพี้ยนหนึ่งหน่วยใน production และสองข้อบกพร่องจริงในการพอร์ตนี้ก็เป็นรูปทรงนั้น ขนาดที่ประกาศยังคงอยู่เป็นค่าคงที่มีชื่อสำหรับการตรวจสอบ: public key, secret key และลายเซ็น 1312 / 2560 / 2420 ไบต์สำหรับ ML-DSA-44, 1952 / 4032 / 3309 สำหรับ ML-DSA-65, 2592 / 4896 / 4627 สำหรับ ML-DSA-87

PDFlibPas หน้า MLDSA44Sign, MLDSA65Sign และ MLDSA87Sign ผ่าน BuildMLDSAParams เข้าเรกคอร์ด TMLDSAParams เดียวที่ฟิลด์ derive ถูกคำนวณแทนการถอด เครื่องยนต์ MLDSASignInternal ตัวเดียวจึงรองรับ parameter set ทั้งสามของ FIPS 204
สาม parameter set ใช้เครื่องยนต์ร่วมกันเพราะเลข set เลือกเพียงเรกคอร์ด และความกว้างบิตที่ derive ถูกคำนวณแทนการถอดจากตาราง FIPS 204

การพอร์ต ML-DSA จากศูนย์พลาดที่ไหนก่อน

ที่ expand_a, FIPS 204 Algorithm 32 และโหมดความล้มเหลวสวยงามอย่างหลอกลวง เมทริกซ์ A ถูก sample โดยหว่าน SHAKE128 ด้วย rho ตามด้วยสองไบต์ index บัฟเฟอร์เมล็ดจึงมี 34 ไบต์: rho(32) ตามด้วย j แล้ว i เขียนใน Pascal ด้วยการ index AnsiString แบบ 1-based สองไบต์นั้นคือ Msg[33] กับ Msg[34] ฉบับร่างแรกของการพอร์ตเขียนมันไปที่ Msg[34] กับ Msg[35] เลื่อนไปหนึ่งไบต์เป๊ะ ๆ ผลคือ key pair ที่ rho ตรงเวกเตอร์ทดสอบสมบูรณ์แต่สัมประสิทธิ์ของ t ผิดทุกตัว มีเพียงเมทริกซ์ที่ปนเปื้อน และเมทริกซ์เป็นสิ่งเดียวที่ public key ไม่ได้แถมมาแบบตรงตัว

อีกสองข้อบกพร่องอาศัยอยู่ใน routine เดียวกัน ความยาว absorb ต้องเป็น 34 ไม่ใช่ 35 ไบต์ขยะหนึ่งไบต์เปลี่ยน stream ที่บีบออกมาทั้งสาย และลูป rejection ชั้นในต้องกลืนทุกกลุ่มสามไบต์ที่บล็อกยื่นให้ รวมถึงกลุ่มที่เริ่มที่ offset 165 ของบล็อก SHAKE128 ขนาด 168 ไบต์ ซึ่งคิดเป็น 56 กลุ่มต่อบล็อก สคริปต์ cross-check ที่หยุดที่ offset 162 ทิ้งหางของทุกบล็อกและทำให้ prefix ของ t1 ที่ถูก sample เพี้ยนไปจากประมาณไบต์ที่สิบสามเป็นต้นไป

SetLength(Msg, 34);
Move(Rho[1], Msg[1], 32);
Msg[33]:= AnsiChar(J);          // ไบต์ index คอลัมน์มาก่อน
Msg[34]:= AnsiChar(I);          // ตามด้วยไบต์ index แถว
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 คงกลุ่มที่ offset 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;

เมื่อแก้ครบสามจุด digest SHA-256 ของ public key กับ secret key ML-DSA-44 เต็มชุดก็ตรงกับเวกเตอร์ known-answer ของ FIPS 204 บทเรียนดีบั๊กหนึ่งข้อก็ควรถูกเรียกชื่อด้วย เพราะมันแพงทั้งเซสชัน: เวลาคุณสร้าง cross-check ภาษา Python ให้ลูป rejection ที่ขับเคลื่อนด้วย XOF, hashlib.shake_128().digest(n) คืน prefix เดิมทุกครั้งที่เรียกแทนการไล่ stream ต่อ หยิบความยาวทั้งหมดครั้งเดียวแล้วหั่นเป็นบล็อกขนาด rate ไม่งั้น reference ของคุณจะยินดีกลืนค่าที่ Pascal ของคุณปฏิเสธถูกต้องกลับไปอีกรอบ

routine expand_a ของ ML-DSA ใน PDFlibPas หว่าน SHAKE128 ด้วยบัฟเฟอร์ 34 ไบต์ที่เก็บ rho, index คอลัมน์และ index แถว ข้าง ๆ ฉบับร่างแรกที่เลื่อนไบต์ index ทั้งคู่ และ cross-check ที่ทิ้งกลุ่มสามไบต์สุดท้ายของทุกบล็อก
ข้อบกพร่องแบบ off-by-one สองข้อใน routine เดียวกัน: ไบต์ index ถูกเขียนช้าไปหนึ่งตำแหน่ง และลูป rejection ที่หยุดก่อนกลุ่มสามไบต์ที่ offset 165

Sampling eta: ทำไม ML-DSA-65 ต้องมีสาขาของตัวเอง

PDFlibPas คงสองเส้นทางแยกใน expand_s เพราะ FIPS 204 Algorithm 33 นิยามสองเส้นทางไว้จริง ๆ สำหรับ eta = 2 แต่ละ nibble ถูกปฏิเสธเมื่อแตะ 15 และมิฉะนั้นลดแบบ mod 5 สำหรับ eta = 4 นิบเบิลถูกปฏิเสธที่ 9 ขึ้นไปแล้วใช้ตรง ๆ โดยไม่ลด modular เลย ML-DSA-65 เป็นชุดเดียวที่ส่งมอบพร้อม eta = 4 และการนำเส้นทาง mod 5 มาใช้แทนจะทำให้ s1 กับ s2 คลาดกันตั้งแต่สัมประสิทธิ์ตัวแรก เกิด key pair ที่สอดคล้องภายใน ตรวจสอบผ่านกับตัวเอง และไม่ตรงกับสิ่งที่ใครก็ตามผลิต

Procedure StoreNibble(Nibble: Byte);
Var
  M: Integer;
  Centered: Cardinal;
Begin
  If Cnt>= N Then
    Exit;
  If Eta= 4 Then
  Begin
    If Nibble>= 9 Then        // ปฏิเสธก่อน แล้วใช้ nibble ตรง ๆ
      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

พารามิเตอร์ encoding สองตัวแปรตามระดับความปลอดภัยในแบบที่มองข้ามได้ง่ายเมื่อบิลด์ ML-DSA-44 ที่ใช้งานได้นั่งอยู่ตรงหน้า hash ของ challenge c-tilde มีขนาด 2 x lambda / 8 ไบต์ คือ 32 สำหรับ ML-DSA-44, 48 สำหรับ ML-DSA-65 และ 64 สำหรับ ML-DSA-87 การปล่อยมันตายตัวที่ 32 ให้ลายเซ็น ML-DSA-65 ขนาด 3293 ไบต์แทนมาตรฐาน 3309 และ prefix ของ KAT เบี่ยงทันที ฟิลด์เรกคอร์ด CTildaBytes มีอยู่พอดีเพื่อไม่ให้ตัวเลขนั้นถูกลืม

ตัวที่สองคือความกว้าง packing ของพหุนาม mask z PDFlibPas คำนวณเป็น BitWidth(Gamma1) ไม่ใช่เลขชี้กำลัง: gamma1 = 2^19 ของ ML-DSA-65 และ 87 ต้องการ 20 บิตต่อสัมประสิทธิ์ ไม่ใช่ 19 และบิตเดียวนั้นตัดสินว่าพหุนาม z แต่ละตัวกิน 640 ไบต์ หรือกินขนาดที่ verifier ไม่มีตัวใดจะ parse ได้ verifier ก็ติดข้อบกพร่องคู่แฝดระหว่างพอร์ต ที่บัฟเฟอร์ deserialization ของ z ถูกกำหนด 192 ไบต์แทน 576 ความยาวลายเซ็นคือ regression test ที่ถูกที่สุดที่คุณจะเคยเขียน: assert 2420, 3309 และ 4627 กับ Length(Signature) แล้วความผิดพลาดส่วนใหญ่ของพารามิเตอร์จะประกาศตัวเองก่อนคุณจะถึงการ assert เชิงเข้ารหัสสักข้อ

PDFlibPas ผูกพารามิเตอร์ encoding สองตัวของ ML-DSA กับระดับความปลอดภัย: hash challenge c-tilde โตจาก 32 เป็น 48 เป็น 64 ไบต์ และพหุนาม mask z ถูก pack ที่ความกว้างบิตของ gamma1 โดยใช้ความยาวลายเซ็นเป็น regression test
สองพารามิเตอร์แปรตามระดับความปลอดภัย และลายเซ็นที่ออกมา 3293 ไบต์แทน 3309 ประกาศความผิดพลาดก่อนการ assert เชิงเข้ารหัสใดจะทำงาน

ลงลายเซ็นโดยไม่มีลูปไม่สิ้นสุด

การลงลายเซ็น ML-DSA อิงการ rejection จึงลองซ้ำด้วย kappa ที่เพิ่มขึ้นจนกว่าลายเซ็นที่เสนอจะผ่านการตรวจ norm กับ hint PDFlibPas กั้นด้วยงบภายนอกแบบชัดเจนที่ 65535 ครั้ง เมื่อหมด MLDSASignInternal คืน False และทิ้งลายเซ็นว่างไว้ แทนที่จะหมุนวนอยู่ในเธรดผลิตเอกสาร ในทางปฏิบัติเวกเตอร์ ML-DSA-44 อย่างเป็นทางการสำเร็จที่ kappa = 4 พร้อม 55 hint เทียบเพดาน omega ที่ 80 งบจึงเป็นราวนิรภัย ไม่ใช่ขีดจำกัดที่ใช้งานจริง

บั๊กที่ทำให้ราวนั้นรู้สึกจำเป็นไม่ใช่เรื่องตัวเลขเลย การลงลายเซ็นดูเหมือนค้าง ความสงสัยตกที่ decompose กับ make_hint (FIPS 204 Algorithms 36 และ 39) และต้นเหตุจริงคือเป้าหมาย accumulate ที่กลับด้าน: เวกเตอร์ที่ป้อนการคำนวณ hint ต้องรวม c*t0 ส่วน c*t0 ดั้งเดิมต้องรอดพร้อมโดยไม่ถูกแตะเพื่อการตรวจ norm เล็งทั้งคู่ไปที่บัฟเฟอร์เดียวกันเมื่อไร ลูปจะปฏิเสธตลอดกาลพร้อมเลขคณิตที่ถูกต้องเป๊ะ บนเส้นทางสำเร็จและเส้นทางงบหมด ยูนิตล้างค่า seed ที่ derive, พหุนามลับ, mask, challenge และบัฟเฟอร์ encoding seed, secret key และ rnd ที่ผู้เรียกส่งเข้ามายังเป็นความรับผิดชอบของผู้เรียก ซึ่งเป็นการแบ่งที่ถูกสำหรับไลบรารีที่ไล่เชื้อสายสตริงพวกนั้นไม่ได้

วันนี้ ML-DSA เจอกับชั้นลายเซ็น PDF ตรงไหน

พูดให้เป๊ะว่าอะไรมีอยู่จริง PDFlibPas ส่งมอบ ML-DSA เป็น primitive ลายเซ็นที่ผ่านการตรวจสอบแล้ว บวกการ bind mechanism PKCS #11 ไม่ใช่ของแทน drop-in สำหรับผลลัพธ์ PAdES ปัจจุบันของคุณ เส้นทาง token คือ TPDFlibPKCS11Client.SignMLDSA และมันถูกตั้งใจให้เป็นจุดเข้าแยก เพราะ CKM_ML_DSA กลืนข้อความดิบแทน digest ที่คำนวณล่วงหน้า SignHash กับ callback external-digest ที่มีอยู่จึงใช้ซ้ำไม่ได้ การค้นหาแบบไม่มีใบรับรองต้องเปิด CertificateOptional อย่างชัดเจนพร้อม label หรือ ID ของ private key และไคลเอนต์ตรวจ CKA_PARAMETER_SET เทียบ whitelist CKP_ML_DSA_44 / 65 / 87 ตอนเชื่อมต่อ คู่ใบรับรอง RSA กับ ECDSA ค่า default จึงไม่มีวันถูกคลายโดยบังเอิญ

การ integrate ระดับเอกสารคือส่วนที่ยังถูกกำกับด้วยงานมาตรฐาน ไม่ใช่โค้ดไลบรารี ISO 32000-2 §12.8 นิยาม signature dictionary และ CMS payload ของมัน และ ISO/TS 32002 เป็นยานพาหะสำหรับต่อยอดการรองรับไปสู่ hash กับอัลกอริทึมลายเซ็นที่ใหม่กว่า ก่อน validator และคู่สัญญาของคุณจะตามทัน การลงลายเซ็นแบบ classical ยังคงเป็นเส้นทาง production ท่าทีที่ใช้ได้จริงคือวิ่งขนานสองราง: คงส่งมอบ ลายเซ็น PAdES B-B ถึง B-LTA พร้อม timestamping และข้อมูล long-term validation สำหรับสิ่งที่บุคคลที่สามต้องตรวจสอบได้ในวันนี้ ระหว่างพิสูจน์การจัดการ key กับ token integration ของ ML-DSA ไปพร้อมกัน สำหรับการทดลองในเครื่อง workflow ใบรับรอง self-signed ที่สร้างบน CryptoAPI แบบเดียวกันให้ตัวตนสำหรับลงลายเซ็นโดยไม่ต้องพึ่ง CA สาธารณะ

ทดสอบการเปลี่ยน parameter set เหมือนที่คุณทดสอบการเปลี่ยนการลงลายเซ็นอื่น ขนาดก่อน ตามด้วยเวกเตอร์อย่างเป็นทางการ แล้วจึงเคสลบ: ไบต์ลายเซ็นที่ถูกปลอมแตะ, context string ที่ไม่ตรง, key ที่ถูกหั่นสั้น PDFlibPas ครอบคลุมทุกข้อในชุด DUnitX และวินัยเดียวกันก็ควรอยู่ในไปป์ไลน์ของคุณ อุดมคติคือคู่กับ workbench ด้าน compliance และการลงลายเซ็นที่รวมการตรวจสอบทั่วคลังเอกสาร เพื่อไม่ให้ regression ไปถึงลูกค้าโดยไม่มีใครรู้

ความพร้อม post-quantum สำหรับซอฟต์แวร์เอกสารจะไม่มาแบบสวิตช์เดียว มันมาเป็น primitive ที่คุณทดสอบได้ เส้นทาง token ที่คุณต่อได้ และรางมาตรฐานที่คุณเดินตามโดยไม่ต้องเดิมพันรีลีสปัจจุบันไว้กับมัน ดูได้ว่ายูนิต ML-DSA วางตัวเคียงเครื่องมือลงลายเซ็น, เข้ารหัสและ PDF/A ที่เหลือใน codebase Object Pascal แบบ native อย่างไร ที่ หน้าผลิตภัณฑ์ PDFlibPas Delphi PDF library ซึ่งลิสต์ชุดคอมโพเนนต์เต็มพร้อมเมทริกซ์คอมไพเลอร์ที่รองรับ