Bài viết kỹ thuật

ML-DSA Delphi: hậu lượng tử FIPS 204 trong PDFlibPas

PDFlibPas dựng ML-DSA, thuật toán chữ ký số dựa trên lattic mô-đun được chuẩn hóa trong FIPS 204, hoàn toàn bằng Object Pascal. Cả ba bộ tham số đều ra mắt dưới dạng hàm thông thường: MLDSA44Sign, MLDSA65Sign, MLDSA87Sign, kèm các entry KeyGen và Verify tương ứng. Không OpenSSL, không DLL nền tảng, không keo dán C. Unit duy nhất PDFlibMLDSA không phụ thuộc gì ngoài sponge SHAKE của thư viện, và đầu ra khớp với các vectơ kiểm tra known-answer chính thức của FIPS 204 từng byte một

Câu cuối đó mới là phần tốn công sức thật. Viết số học lattic bằng Pascal là chuyện máy móc; làm cho nó đồng thuận với NIST thì không. Sau đây là bản tường thuật kỹ thuật của việc port: ba bộ tham số chung sống thế nào với một engine duy nhất, cùng những lỗi cụ thể đã tách biên dịch và chạy được khỏi khớp với KAT. Nếu bạn đang thẩm định các lựa chọn hậu lượng tử cho một pipeline tài liệu Delphi hay C++Builder, những lỗi đó mới là phần hữu ích, vì mỗi lỗi đều tạo ra đầu ra nhìn rất đáng tin nhưng âm thầm trượt interoperability

Vì sao viết một bộ ký hậu lượng tử thuần Object Pascal?

Vì phương án thay thế là một dependency gốc cho mỗi target, mà một thư viện PDF Delphi đã có sẵn quá nhiều thứ dạng đó. PDFlibPas build trên Delphi, C++Builder và FPC/Lazarus với các target Win32, Win64 và Unix; gắn một thư viện hậu lượng tử bằng C đồng nghĩa phải theo sát một bản build của nó cho từng slot, cộng thêm bề mặt quy ước gọi và quyền sở hữu bộ nhớ giữa hai bên. Một unit Pascal thuần biên dịch ở bất cứ đâu phần còn lại của thư viện biên dịch được, và đó là toàn bộ lập luận

ML-DSA khiến việc này rẻ một cách bất thường, vì dependency nguyên thủy duy nhất của nó là SHAKE. Không tầng số nguyên lớn, không đường cong eliptic, không bộ hash riêng. PDFlibPas có thêm một XOF streaming ngay trong bản phát hành liền trước lúc port: TPLShakeXOF trong PDFlibDigest, với PLShakeXOFInit chọn SHAKE128 (rate 168) hoặc SHAKE256 (rate 136), tiếp theo là PLShakeXOFAbsorb, PLShakeXOFFinalize và vòng lặp PLShakeXOFSqueeze hoán vị liên tục cho độ dài đầu ra tùy ý. Mọi thủ tục rejection-sampling trong unit ML-DSA đều viết trực tiếp trên API bốn lời gọi đó

Một engine, ba bộ tham số: TMLDSAParams

PDFlibPas mô tả trọn một bộ tham số ML-DSA bằng một record và chọn nó theo số thứ tự bộ, nên ML-DSA-44, 65 và 87 đi qua cùng các đường code. Bản cài đặt đầu tiên chạy được là một bản build 4x4 cố định cài cứng cho ML-DSA-44; khái quát hóa nó nghĩa là nhấc k và l, eta, tau, beta, gamma1 và gamma2, omega cùng độ dài challenge vào TMLDSAParams, rồi suy ra mọi thứ còn lại. Các entry point công khai hóa thành những wrapper ba dòng

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;

// Các trường suy ra được tính, không bao giờ chép từ bảng
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;

Năm trường suy ra được tính thay vì chép ra từ các bảng của FIPS 204 là chủ ý. Bit width chép tay đúng là lớp hằng số nhìn rất đúng lúc review rồi lệch một đơn vị khi lên production, và hai trong số các lỗi thật của lần port này thuộc đúng dạng đó. Các kích thước khai báo vẫn giữ làm hằng số có tên để kiểm chứng: 1312 / 2560 / 2420 byte cho khóa công khai, khóa bí mật và chữ ký của ML-DSA-44, 1952 / 4032 / 3309 cho ML-DSA-65, 2592 / 4896 / 4627 cho ML-DSA-87

PDFlibPas dẫn MLDSA44Sign, MLDSA65Sign và MLDSA87Sign qua BuildMLDSAParams vào một record TMLDSAParams duy nhất mà các trường suy ra được tính chứ không chép, nên một engine MLDSASignInternal dùng chung phục vụ cả ba bộ tham số FIPS 204
Ba bộ tham số dùng chung một engine vì số thứ tự bộ chỉ chọn một record, còn bit width suy ra được tính thay vì chép từ các bảng FIPS 204

Một lần port ML-DSA từ đầu thường hỏng đầu tiên ở đâu?

expand_a, thuật toán 32 của FIPS 204, và dạng hỏng đẹp một cách đánh lừa. Ma trận A được lấy mẫu bằng cách seed SHAKE128 với rho rồi đến hai byte chỉ số, nên bộ đệm seed dài 34 byte: rho(32), rồi j, rồi i. Viết bằng Pascal với chỉ số AnsiString bắt đầu từ 1 thì hai byte đó là Msg[33]Msg[34]. Bản thảo đầu tiên của lần port ghi chúng vào Msg[34]Msg[35], lệch đúng một byte, và kết quả là một cặp khóa mà rho khớp hoàn hảo với vectơ kiểm tra trong khi mọi hệ số của t đều sai. Chỉ có ma trận bị ô nhiễm, và ma trận lại là thứ duy nhất khóa công khai không mang theo nguyên văn

Hai lỗi nữa nằm ngay trong cùng thủ tục. Độ dài absorb phải là 34, không phải 35; một byte rác thừa đổi cả dòng stream squeezed. Và vòng lặp rejection bên trong phải tiêu thụ mọi nhóm ba byte mà khối cung cấp được, kể cả nhóm bắt đầu tại offset 165 của khối SHAKE128 dài 168 byte, tức 56 nhóm mỗi khối. Một script đối chiếu chặn ở offset 162 đã bỏ đi phần đuôi của mọi khối và làm trôi đoạn prefix t1 được lấy mẫu từ khoảng byte thứ mười ba trở đi

SetLength(Msg, 34);
Move(Rho[1], Msg[1], 32);
Msg[33]:= AnsiChar(J);          // chỉ số cột trước
Msg[34]:= AnsiChar(I);          // rồi mới đến chỉ số hàng
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 giữ lại nhóm tại offset 165: 56 bộ ba mỗi khối
  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;

Sửa xong ba điểm đó, digest SHA-256 của trọn khóa công khai và khóa bí mật ML-DSA-44 đã khớp các vectơ known-answer của FIPS 204. Một bài học debug cũng đáng nêu, vì nó ngốn cả một buổi làm việc: khi dựng script đối chiếu Python cho một vòng lặp rejection chạy bằng XOF, hashlib.shake_128().digest(n) trả về cùng một prefix ở mỗi lần gọi thay vì tiếp tục dòng stream. Hãy lấy cả độ dài một lần, rồi cắt thành các khối cỡ rate, nếu không phần tham chiếu của bạn sẽ vui vẻ tiêu thụ lại đúng những giá trị mà Pascal của bạn đã từ chối đúng đắn

Thủ tục expand_a của ML-DSA trong PDFlibPas seed SHAKE128 bằng bộ đệm 34 byte chứa rho, chỉ số cột và chỉ số hàng, cạnh một bản thảo đầu tiên dịch cả hai byte chỉ số và một script đối chiếu bỏ sót nhóm ba byte cuối của mọi khối
Hai lỗi lệch một đơn vị trong cùng thủ tục: byte chỉ số ghi trễ một vị trí, và một vòng lặp rejection dừng trước nhóm ba byte tại offset 165

Lấy mẫu eta: vì sao ML-DSA-65 cần nhánh riêng

PDFlibPas giữ hai đường riêng trong expand_s vì thuật toán 33 của FIPS 204 thực sự định nghĩa hai đường. Với eta = 2, mỗi nibble bị từ chối khi chạm 15 và nếu không thì giảm mod 5. Với eta = 4, nibble bị từ chối ở mức 9 trở lên rồi được dùng nguyên giá trị, không có phép giảm mô-đun nào cả. ML-DSA-65 là bộ duy nhất được phát hành với eta = 4, và tái dùng đường mod 5 cho nó làm lệch s1 và s2 ngay từ hệ số đầu tiên, tạo ra một cặp khóa tự nhất quán, tự kiểm chứng với chính nó, và chẳng khớp với thứ gì ai khác tạo ra

Procedure StoreNibble(Nibble: Byte);
Var
  M: Integer;
  Centered: Cardinal;
Begin
  If Cnt>= N Then
    Exit;
  If Eta= 4 Then
  Begin
    If Nibble>= 9 Then        // từ chối, rồi lấy nibble nguyên giá trị
      Exit;
    M:= Nibble;
  End
  Else
  Begin
    If Nibble>= 15 Then       // eta = 2: từ chối 15, rồi giảm 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;

Kích thước chính là bài test: độ dài c-tilde và bit width gamma1

Hai tham số mã hóa thay đổi theo mức bảo mật theo cách dễ bỏ sót khi một bản build ML-DSA-44 chạy tốt đang nằm ngay đó. Challenge hash c-tilde dài 2 x lambda / 8 byte, tức 32 với ML-DSA-44, 48 với ML-DSA-65 và 64 với ML-DSA-87. Giữ cố định ở 32 cho ra chữ ký ML-DSA-65 dài 3293 byte thay vì 3309 chuẩn, và prefix KAT phân kỳ ngay lập tức. Trường record CTildaBytes tồn tại chính là để con số đó không thể bị quên

Thứ hai là độ rộng đóng gói của đa thức mask z. PDFlibPas tính nó bằng BitWidth(Gamma1), không phải bằng số mũ: gamma1 = 2^19 với ML-DSA-65 và 87 cần 20 bit mỗi hệ số, không phải 19, và đúng một bit đó quyết định mỗi đa thức z chiếm 640 byte hay một thứ mà không verifier nào chịu parse. Verifier cũng mang một lỗi tương tự trong lúc port, khi bộ đệm deserialization của z được cấp 192 byte thay vì 576. Độ dài chữ ký là bài regression test rẻ nhất bạn từng viết: assert 2420, 3309 và 4627 với Length(Signature) và hầu hết sai sót tham số hóa sẽ tự tiết lộ trước khi bạn chạm tới một assert mật mã nào

PDFlibPas buộc hai tham số mã hóa ML-DSA vào mức bảo mật: challenge hash c-tilde phình từ 32 lên 48 rồi 64 byte, và đa thức mask z được đóng gói theo BitWidth bit của gamma1, với độ dài chữ ký làm bài regression test
Hai tham số thay đổi theo mức bảo mật, và một chữ ký ra 3293 byte thay vì 3309 đã báo trước sai sót trước khi bất kỳ assert mật mã nào chạy

Ký mà không có vòng lặp vô hạn

Ký ML-DSA dựa trên rejection, nên nó thử lại với kappa tăng dần cho đến khi một chữ ký ứng viên qua được các phép kiểm tra norm và hint. PDFlibPas chặn điều đó bằng một ngân sách ngoài tường minh 65535 lần thử; khi cạn ngân sách, MLDSASignInternal trả về False và để chữ ký trống thay vì quay mãi trong một thread sản xuất tài liệu. Thực tế, vectơ chính thức ML-DSA-44 thành công tại kappa = 4 với 55 hint so với trần omega là 80, nên ngân sách đó là lan can an toàn chứ không phải giới hạn vận hành

Lỗi khiến lan can đó cảm giác như cần thiết lại không phải lỗi số học chút nào. Ký tự nhiên bị treo, nghi vấn đổ dồn vào decomposemake_hint (thuật toán 36 và 39 của FIPS 204), còn nguyên nhân thật là một đích tích lũy bị đảo: vectơ cấp dữ liệu cho phép tính hint phải tích lũy c*t0, trong khi c*t0 gốc phải nguyên vẹn sống sót qua phép kiểm tra norm. Nhắm cả hai vào cùng một bộ đệm và vòng lặp từ chối mãi mãi với số học chính xác hoàn hảo. Trên cả hai đường thành công lẫn cạn ngân sách, unit zero hóa các seed suy ra, đa thức bí mật, mask, challenge và các bộ đệm mã hóa; seed, khóa bí mật và rnd do caller cung cấp vẫn thuộc trách nhiệm của caller, một ranh giới đúng cho thư viện không thể biết những chuỗi đó đến từ đâu

ML-DSA gặp chồng chữ ký PDF ở đâu ngày nay?

Hãy chính xác về cái gì đang tồn tại. PDFlibPas phát hành ML-DSA dưới dạng các nguyên thủy chữ ký đã kiểm chứng cùng một binding cơ chế PKCS #11, chứ không phải một giải pháp thay thế thả vào cho đầu ra PAdES hiện tại của bạn. Đường token là TPDFlibPKCS11Client.SignMLDSA, và nó cố ý là một entry point riêng vì CKM_ML_DSA tiêu thụ thông điệp thô thay vì digest đã tính sẵn, nên các callback SignHash và external-digest hiện có không thể tái sử dụng. Việc khám phá không cần chứng thư yêu cầu bật CertificateOptional tường minh cùng một nhãn hoặc ID khóa riêng, và client kiểm chứng CKA_PARAMETER_SET với whitelist CKP_ML_DSA_44 / 65 / 87 ngay lúc connect, nên cặp ghép chứng thư RSA và ECDSA mặc định không bao giờ bị nới lỏng do tai nạn

Tích hợp ở cấp tài liệu là phần vẫn do công việc chuẩn hóa điều hành chứ không phải do code thư viện. ISO 32000-2 §12.8 định nghĩa signature dictionary cùng payload CMS của nó, và ISO/TS 32002 là phương tiện để mở rộng sự hỗ trợ đó sang các thuật toán hash và chữ ký mới hơn; cho đến khi các validator và đối tác của bạn theo kịp, ký cổ điển vẫn là đường production. Tư thế thực dụng là hai ray song song: tiếp tục phát hành chữ ký PAdES B-B đến B-LTA với timestamping và dữ liệu kiểm định dài hạn cho bất cứ thứ gì bên thứ ba phải kiểm chứng ngay hôm nay, song song với việc chứng minh khả năng xử lý khóa và tích hợp token ML-DSA. Cho thí nghiệm cục bộ, quy trình chứng thư tự ký xây trên CryptoAPI cũng chính là thứ cấp cho bạn một danh tính ký mà không cần CA công cộng

Hãy kiểm tra một thay đổi bộ tham số theo cách bạn kiểm tra bất kỳ thay đổi ký nào khác. Kích thước trước, rồi các vectơ chính thức, rồi các ca tiêu cực: một byte chữ ký bị can thiệp, một chuỗi context lệch, một khóa bị cắt cụt. PDFlibPas che phủ tất cả những điều đó trong bộ DUnitX của mình, và kỷ luật tương tự thuộc về pipeline của bạn, lý tưởng nhất là cạnh workbench tuân thủ và ký gom kiểm định theo lô trên cả kho tài liệu để một regression không bao giờ lọt tới khách hàng mà không ai hay

Sẵn sàng hậu lượng tử cho phần mềm tài liệu sẽ không đến như một công tắc duy nhất. Nó đến dưới dạng các nguyên thủy bạn kiểm tra được, một đường token bạn nối được, và một lộ trình chuẩn bạn bám theo mà không phải đặt cược bản phát hành hiện tại vào đó. Để xem unit ML-DSA đứng cạnh phần còn lại của công cụ ký, mã hóa và PDF/A trong một codebase Object Pascal gốc, trang sản phẩm PDFlibPas Delphi PDF library liệt kê trọn bộ component cùng ma trận trình biên dịch được hỗ trợ