技术文章

Delphi 的 ML-DSA:PDFlibPas 纯 Pascal 后量子实现

PDFlibPas 完全用 Object Pascal 实现了 ML-DSA,即 FIPS 204 标准化的基于模块格的数字签名算法。三个参数集全部以普通函数形式交付:MLDSA44SignMLDSA65SignMLDSA87Sign,外加配套的 KeyGen 和 Verify 入口。没有 OpenSSL,没有平台 DLL,没有 C 胶水。单个单元 PDFlibMLDSA 只依赖库内 SHAKE 海绵结构,其输出与官方 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(速率 168)或 SHAKE256(速率 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 算法 32,而失败形态迷惑性极强。矩阵 A 的采样是用 rho 加两个索引字节做 SHAKE128 的种子,所以种子缓冲区是 34 字节:rho(32),然后 j,然后 i。用 1 起始的 AnsiString 索引写 Pascal,这两个字节是 Msg[33]Msg[34]。这次移植的第一稿把它们写进了 Msg[34]Msg[35],整整偏移一个字节,结果是一对密钥,其 rho 与测试向量完美匹配,而 t 的每个系数都是错的。只有矩阵被污染,而矩阵恰恰是公钥不逐字携带的那一样东西

同一个例程里还住着两个缺陷。吸收长度必须是 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,速率 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) 每次调用都返回相同前缀,而不是继续推进流。一次性取完整长度,再切成速率大小的块,否则你的参考实现会高高兴兴地重新消费你的 Pascal 已正确拒绝的那些值

PDFlibPas 的 ML-DSA expand_a 例程用 34 字节缓冲区做 SHAKE128 的种子,缓冲区装 rho、列索引和行索引;旁边是第一稿把两个索引字节都写偏一位,以及交叉校验丢掉了每块最后一个三字节组
同一例程里的两个差一缺陷:索引字节写晚一位,以及拒绝循环停在偏移 165 处的三字节组之前

采样 eta:为什么 ML-DSA-65 需要自己的分支

PDFlibPas 在 expand_s 中保留两条独立路径,因为 FIPS 204 算法 33 真的定义了两条。eta = 2 时,半字节达到 15 就拒绝,否则按 mod 5 约减。eta = 4 时,半字节在 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        // 拒绝;通过则原样取该半字节
      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 字节的 ML-DSA-65 签名而不是标准的 3309,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 of gamma1 位打包,签名长度充当回归测试
两个参数随安全级别变化,一个出来是 3293 字节而非 3309 的签名会在任何密码学断言运行之前就把错误报出来

不用无界循环完成签名

ML-DSA 签名基于拒绝采样,所以它会以递增的 kappa 重试,直到候选签名通过范数和提示检查。PDFlibPas 用 65535 次尝试的显式外部预算给它封顶;预算耗尽时 MLDSASignInternal 返回 False 并把签名留空,而不是在文档生产线程里空转。实践中官方 ML-DSA-44 向量在 kappa = 4 时以 55 个提示成功,对 omega 上限 80,所以预算是安全护栏而不是工作极限

让这道护栏显得必要的缺陷根本不是数值问题。签名看上去挂死了,怀疑落到 decomposemake_hint(FIPS 204 算法 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 就有签名身份

测试参数集变更要像测试任何其他签名变更一样。先尺寸,再官方向量,再负向用例:篡改一个签名字节、换一串不匹配的上下文字符串、截断密钥。PDFlibPas 在其 DUnitX 套件中覆盖所有这些,同样的纪律也属于你自己的管线,最好配合跨文档语料批量验证的合规与签名工作台,让回归永远到不了客户眼前

文档软件的后量子就绪不会以单个开关的形式到来。它以可测试的原语、可接线的令牌路径、以及一条你跟着走但不拿当前版本下注的标准轨道的形式到来。要看 ML-DSA 单元如何与原生 Object Pascal 代码库中其余签名、加密和 PDF/A 工具并排放置,PDFlibPas Delphi PDF 库产品页列出了完整组件集和支持的编译器矩阵