技術文章

Delphi 中的 ML-DSA:PDFlibPas 的 FIPS 204 後量子實作

PDFlibPas 完全以 Object Pascal 實作了 ML-DSA——FIPS 204 標準化的模組格數位簽章演算法。三組參數集都以普通函式的形式提供:MLDSA44SignMLDSA65SignMLDSA87Sign,外加對應的 KeyGen 與 Verify 入口。沒有 OpenSSL,沒有平台 DLL,沒有 C 膠水。唯一的單元 PDFlibMLDSA 只依賴函式庫內的 SHAKE sponge,其輸出與官方 FIPS 204 已知答案測試向量逐位元組一致

最後那句話是唯一真正花工夫的部分。用 Pascal 寫格運算是機械性的工作;讓它與 NIST 一致不是。以下是這次移植的工程紀實:三組參數集如何最終共用一個引擎,以及把能編譯能跑對得上 KAT 隔開的那些具體缺陷。如果您正在為 Delphi 或 C++Builder 文件管線評估後量子選項,這些缺陷才是有用的部分,因為它們每一個都會產生看似合理、卻靜靜失去互通性的輸出

為什麼要用純 Object Pascal 寫後量子簽章器?

因為替代方案是每個目標平台各一份原生依賴,而一套 Delphi PDF 函式庫的這類依賴已經夠多了。PDFlibPas 跨 Delphi、C++Builder 與 FPC/Lazarus 建置,涵蓋 Win32、Win64 與 Unix 目標;繫結一套 C 後量子函式庫意味著要為其中每一格追蹤一份對應建置,外加它們之間的呼叫慣例與記憶體所有權介面。一個純 Pascal 單元能編譯到函式庫其餘部分所及的任何地方,這就是全部理由

ML-DSA 讓這件事異常便宜,因為它唯一的基元依賴是 SHAKE。沒有大整數層,沒有橢圓曲線,沒有獨立的雜湊套件。PDFlibPas 在移植前的那個版本剛好獲得了一個串流式 XOF:PDFlibDigest 中的 TPLShakeXOF,其中 PLShakeXOFInit 選擇 SHAKE128(rate 168)或 SHAKE256(rate 136),接著是 PLShakeXOFAbsorbPLShakeXOFFinalize,以及一個持續置換以取得任意長度輸出的 PLShakeXOFSqueeze 迴圈。ML-DSA 單元裡每個拒絕取樣常式都直接寫在這個四呼叫 API 之上

一個引擎、三組參數集:TMLDSAParams

PDFlibPas 用一個記錄描述整組 ML-DSA 參數集,並按集合編號選取,所以 ML-DSA-44、65 與 87 走相同的程式碼路徑。第一個能跑的實作是為 ML-DSA-44 寫死的 4x4 建置;一般化意味著把 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 表格裡轉抄的。手工轉抄的位元寬度正是那種在審查時看起來沒錯、在生產環境差一位的常數,而這次移植中兩個真實缺陷就屬於這一類。宣告的尺寸保留為具名常數供驗證用:ML-DSA-44 的公鑰、私鑰與簽章分別為 1312 / 2560 / 2420 位元組,ML-DSA-65 為 1952 / 4032 / 3309,ML-DSA-87 為 2592 / 4896 / 4627

PDFlibPas 把 MLDSA44Sign、MLDSA65Sign 與 MLDSA87Sign 經由 BuildMLDSAParams 導入單一 TMLDSAParams 記錄,其衍生欄位為計算而非轉抄,於是同一個 MLDSASignInternal 共用引擎服務全部三組 FIPS 204 參數集
三組參數集共用一個引擎,因為集合編號只是選取一筆記錄,而衍生位元寬度是計算得來,而非從 FIPS 204 表格轉抄

從零移植 ML-DSA 會先在哪裡出錯?

expand_a,也就是 FIPS 204 Algorithm 32,而且失敗模式美得會誤導人。矩陣 A 的取樣是以 rho 接上兩個索引位元組作為 SHAKE128 的種子,所以種子緩衝區是 34 位元組:rho(32),然後是 j,再來是 i。用 1 起始的 AnsiString 索引寫成 Pascal,那兩個位元組是 Msg[33]Msg[34]。這次移植的第一稿把它們寫進了 Msg[34]Msg[35],恰好偏移一個位元組,結果是一對 rho 與測試向量完全吻合、但 t 的每個係數全錯的金鑰對。只有矩陣被污染,而矩陣恰恰是公鑰不會逐字攜帶的唯一東西

同一個常式裡還住了另外兩個缺陷。absorb 長度必須是 34 而不是 35;多出一個垃圾位元組會改變整個擠出的串流。而內層拒絕迴圈必須消化一個區塊能提供的每個三位元組組,包括從 168 位元組 SHAKE128 區塊的偏移 165 開始的那組,也就是每個區塊 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;

修正這三處之後,完整的 ML-DSA-44 公鑰與私鑰的 SHA-256 摘要對上了 FIPS 204 已知答案向量。還有一條除錯教訓值得點名,因為它花掉了一個工作階段:當您為 XOF 驅動的拒絕迴圈寫 Python 交叉檢查時,hashlib.shake_128().digest(n) 每次呼叫都回傳相同的前綴,而不是延續串流。一次取足整段長度,再切成 rate 大小的區塊,否則您的參照實作會開心地重新消費那些您的 Pascal 正確拒絕掉的值

PDFlibPas 的 ML-DSA expand_a 常式以 34 位元組緩衝區作為 SHAKE128 種子,內含 rho、行索引與列索引;旁邊對照第一稿把兩個索引位元組都錯移一位的版本,以及丟掉每個區塊最後一個三位元組組的交叉檢查
同一常式裡的兩個差一缺陷:索引位元組晚寫了一個位置,以及拒絕迴圈在偏移 165 的三位元組組之前就停下

取樣 eta:為什麼 ML-DSA-65 需要自己的分支

PDFlibPas 在 expand_s 中保留兩條獨立路徑,因為 FIPS 204 Algorithm 33 真的定義了兩條。eta = 2 時,每個 nibble 達到 15 即拒絕,否則取 mod 5 縮減。eta = 4 時,nibble 在 9 以上即拒絕,然後直接使用,完全不做模縮減。ML-DSA-65 是唯一出貨的 eta = 4 集合,對它複用 mod 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        // 拒絕,否則原樣取用該 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 位元寬度

有兩個編碼參數隨安全等級變化,而在一個能跑的 ML-DSA-44 建置就在眼前時很容易漏看。挑戰雜湊 c-tilde 是 2 x lambda / 8 位元組,ML-DSA-44 為 32,ML-DSA-65 為 48,ML-DSA-87 為 64。把它留成固定的 32,會得到 3293 位元組而非標準 3309 位元組的 ML-DSA-65 簽章,KAT 前綴立刻分歧。記錄欄位 CTildaBytes 存在的意義正是讓這個數字無法被遺忘

第二個是遮罩多項式 z 的打包寬度。PDFlibPas 以 BitWidth(Gamma1) 計算,而不是取指數:ML-DSA-65 與 87 的 gamma1 = 2^19 每個係數需要 20 位元,不是 19,而這一個位元決定每個 z 多項式佔 640 位元組還是某個沒有驗證器解析得了的東西。移植期間驗證器帶著一個對稱的缺陷,z 反序列化緩衝區被定為 192 位元組而非 576。簽章長度是您能寫的最便宜的回歸測試:對 Length(Signature) 斷言 2420、3309 與 4627,大多數參數化錯誤會在您碰到任何密碼學斷言之前自己現形

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,所以這個預算是安全護欄而非工作上限

讓這道護欄顯得必要的臭蟲根本不是數值問題。簽章看似卡死,懷疑落在 decomposemake_hint(FIPS 204 Algorithms 36 與 39)頭上,而真正的原因是一個寫反的累積目標:餵給提示計算的向量必須累積 c*t0,而原始的 c*t0 必須原封不動地留給範數檢查。兩者指向同一個緩衝區,迴圈就會用完全正確的算術永遠拒絕下去。在成功與預算耗盡兩條路徑上,單元都會清零衍生種子、私有多項式、遮罩、挑戰值與編碼緩衝區;呼叫方提供的種子、私鑰與 rnd 仍由呼叫方負責,對一套無法知道那些字串來歷的函式庫來說這是正確的分工

ML-DSA 今天與 PDF 簽章堆疊在哪裡交會?

精確說明現況。PDFlibPas 提供的是經驗證的簽章基元加上 PKCS #11 機制繫結,而不是您目前 PAdES 輸出的直接替代品。權杖路徑是 TPDFlibPKCS11Client.SignMLDSA,它刻意是獨立入口,因為 CKM_ML_DSA 消費的是原始訊息而非預算好的摘要,所以既有的 SignHash 與外部摘要回呼無法複用。無憑證探索需要明確啟用 CertificateOptional 並搭配私鑰標籤或 ID,且用戶端在連線時以 CKP_ML_DSA_44 / 65 / 87 白名單驗證 CKA_PARAMETER_SET,所以預設的 RSA 與 ECDSA 憑證配對絕不會被意外放寬

文件層級的整合屬於仍由標準制定工作主導的部分,而非函式庫程式碼。ISO 32000-2 §12.8 定義了簽章字典及其 CMS 負載,ISO/TS 32002 是把該支援擴展到更新雜湊與簽章演算法的載具;在您的驗證器與交易對手跟上之前,傳統簽章仍是生產路徑。務實的姿態是雙軌並行:對任何今天需要第三方驗證的東西,繼續提供 帶時間戳與長期驗證資料的 PAdES B-B 至 B-LTA 簽章,同時在旁邊驗證 ML-DSA 金鑰處理與權杖整合。本地實驗方面,同一套建於 CryptoAPI 之上的自簽憑證工作流程讓您無需公共 CA 就能擁有簽章身分

測試參數集變更的方式,就照您測試任何其他簽章變更的方式。先尺寸,再官方向量,再負面案例:被竄改的簽章位元組、不符的 context 字串、截斷的金鑰。PDFlibPas 在其 DUnitX 套件中涵蓋這些全部,同樣的紀律也該進入您自己的管線,最好與對文件語料庫批量驗證的合規與簽章工作台並行,讓回歸永遠不會在無人察覺的情況下抵達客戶

文件軟體的後量子就緒不會以單一開關的形式到來。它以可測試的基元、可接線的權杖路徑,以及一條您不必賭上當前版本就能跟隨的標準軌道的形式到來。想了解 ML-DSA 單元如何與原生 Object Pascal 程式碼庫中的其餘簽章、加密與 PDF/A 工具並存,PDFlibPas Delphi PDF library 產品頁列出了完整元件集與支援的編譯器矩陣