技术文章

Delphi 里按 RFC 4055 编码 RSASSA-PSS-params

一份以 A0 0F … A1 1C … 02 01 20 02 01 01 结尾的 RSASSA-PSS signatureAlgorithm 十六进制转储看着很完整:SHA-256、MGF1、32 字节盐值、trailer 为 1。严格的解码器会把整个参数块拒之门外,宽松的解码器则把盐长读成 20。无论哪种,一份 RSA 算术完全正确的签名都会被判无效。RFC 4055 §3.1 说明了原因:RSASSA-PSS-params 的每个字段都带显式上下文标签,所以盐值必须待在 A2 里面,而在 DER 下等于默认值的 trailerField 根本不许出现

于是一份 CMS 签名有了两半,各自可以独立失败。一半是密码学运算;另一半是告诉校验器运算如何执行的 ASN.1。第一半做对、第二半做错,守规范的校验器没有任何办法把你的文档和伪造品区分开。本文讲的就是 ASN.1 这一半

字节明明正确的 RSASSA-PSS 签名,校验器为什么拒绝?

因为在所有 RSA 方案里,只有 RSASSA-PSS 让校验器无法从算法 OID 反推出参数。PKCS#1 v1.5 被 sha256WithRSAEncryption 完全钉死,参数是一根裸 NULL。PSS 由消息哈希、自带哈希的掩码生成函数和盐长三个东西参数化,而 RFC 8017 §A.2.3 把三者全留给签名方。AlgorithmIdentifier 装着签名方的选择,校验器必须在 EMSA-PSS-VERIFY 能跑之前逐位复现它们

校验器解析不了的参数块,在任何模幂运算发生之前就终止验证。能解析、但解析得不一样的参数块更糟。RFC 4055 §3.1 给 saltLength 的默认值是 20,找不到盐字段的解码器会拿 20 字节盐去跑 EMSA-PSS-VERIFY,对抗的却是一把按 32 字节算出的签名。结果被报告为坏签名,没有任何东西指向元数据。校验器的严格程度决定你看到两种失败中的哪一种,而两种报错都不会提 AlgorithmIdentifier

RSASSA-PSS-params 的四个字段与它们的标签

RFC 4055 §3.1 把 RSASSA-PSS-params 定义为四字段的 SEQUENCE,每个字段带显式上下文相关标签和 DEFAULT 值。DER 里,显式打标签把每个字段包进一个构造型的上下文 TLV——A0 到 A3——字段自己的通用编码嵌在里面:

字段标签(DER 字节)DEFAULTETSI TS 119 312 §7 取值编码?
hashAlgorithm[0](A0)sha1SHA-256是
maskGenAlgorithm[1](A1)mgf1SHA1MGF1 配 SHA-256是
saltLength[2](A2)2032(摘要长度)是
trailerField[3](A3)trailerFieldBC(1)1否,等于默认值

要标签,是因为每个字段都能经默认值变成可选。hashAlgorithm 和 maskGenAlgorithm 都是 AlgorithmIdentifier SEQUENCE;没有标签,一个盯着只含一条 SEQUENCE 的参数块的解码器,分不出它是两者中的哪一个。有了标签,无论编码器省掉哪些邻居,标签号都点名了字段

PDFium Component 中按 RFC 4055 画的 RSASSA-PSS-params 示意图:hashAlgorithm A0、maskGenAlgorithm A1 和 saltLength A2 带显式上下文标签和 DEFAULT 值,ETSI 档案发出 SHA-256、配 SHA-256 的 MGF1 和盐 32,trailerField A3 等于 trailerFieldBC,DER 因此整个省略它
每个字段都打标签,恰恰因为每个字段都能经默认值变成可选,所以无论编码器省掉哪些邻居,标签号都能认出字段

盐值取摘要长度,然后别动。有些 API 提供「最大盐值」或「盐长自动」,产出的值随密钥尺寸变化;这在 RFC 8017 下合法,但 AlgorithmIdentifier 随之要声明一个逐密钥变化的数,而 SHA-256 配 32 字节盐本来就是 ETSI TS 119 312 §7 的要求。PDFium Component 的内建签名后端全部把它钉在 32,并把同一个数传给平台调用:Windows 上交给 NCryptSignHash 的 BCRYPT_PSS_PADDING_INFO 里的 cbSalt,PKCS#11 token 上 CK_RSA_PKCS_PSS_PARAMS 里的 sLen,OpenSSL 下的 EVP_PKEY_CTX_set_rsa_pss_saltlen,以及 macOS 上 kSecKeyAlgorithmRSASignatureDigestPSSSHA256 隐含的摘要长度盐

为自己的 ICmsSigner 写 GetSignatureAlgorithmParams

PDFium Component 里,每个签名后端都实现 FPdfCms.pas 的 ICmsSigner 接口,参数块来自其中一个方法:GetSignatureAlgorithmParams。CMS 构建器经 TDerWriter.AlgIdWithParams 把它的结果与来自 GetSignatureAlgorithmOid 的 OID 配成一对。你会在经 ConfigurePadesSignerProvider 接入自己的签名方时遇到这个方法——一台网络 HSM、一个签名服务、一块内建后端覆盖不到的智能卡驱动。多数人第一版编码会给两个 AlgorithmIdentifier 打标签——它们显然需要包一层——而把两个整数留成裸的:

// 常见的第一版:[0] 和 [1] 打了标签,[2] 和 [3] 没有
Result := W.Sequence(Concat4(
  W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
  W.ContextSpecific(1, W.Sequence(ConcatBytes(
    W.OID('1.2.840.113549.1.1.8'),
    W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
  W.IntegerOf(32),      // 这里要求 [2] EXPLICIT,却是裸 INTEGER
  W.IntegerOf(1)));     // trailerField 等于 DEFAULT:必须缺席

按解码器的方式走一遍。它看到 A0 读出哈希,看到 A1 读出掩码生成函数,然后撞上 02 01 20——一个通用 INTEGER。RSASSA-PSS-params 的任何地方都没有不带标签的 INTEGER 成员,严格解码器就此停下。宽松的解码器跳过这个未知元素,永远找不到 A2,按默认盐 20 处理,接着又被第二个流浪的 INTEGER、02 01 01 绊倒。两条路都到不了 32

RSASSA-PSS-params 打标签陷阱示意图:A0 和 A1 之后,错打标签的块在本该属于 A2 显式标签的位置撞上裸的 02 01 20,严格解码器停下、宽松解码器回落到默认 20 字节盐,而正确的编码把 32 字节盐包进 A2
RSASSA-PSS-params 没有不带标签的 INTEGER 成员,这里的流浪字节要么是解析失败、要么是悄悄回落到默认盐长,两条路都到不了签名方的 32

守规范的版本用 FPdfAsn1.pas 里的 TDerWriter.ContextSpecific 调三次——每个非默认字段一次——每次都把 Constructed 设为 True,结果才是显式包装。trailer 字段没有那一行。这与 Windows CNG、macOS Keychain、PKCS#11 和 OpenSSL 后端发出的是同一块,这里按自定义签名方会带的样子写出来:

uses
  System.SysUtils, FPdfSha256, FPdfAsn1, FPdfCms;

type
  TMyHsmSigner = class(TInterfacedObject, ICmsSigner)
  private
    FUsePss: Boolean;
  public
    function GetDigestAlgorithmOid: AnsiString;
    function GetSignatureAlgorithmOid: AnsiString;
    function GetSignatureAlgorithmParams: TBytes;
    function GetSignerId: TCmsSignerId;
    function SignSignedAttrsDigest(const Digest: TSHA256Digest): TBytes;
  end;

// 你自己的类;另外四个方法与你的 HSM 对话,此处不展示
function TMyHsmSigner.GetSignatureAlgorithmParams: TBytes;
var
  W: TDerWriter;
begin
  if not FUsePss then
    Exit(nil);   // v1.5 和 ECDSA:AlgIdWithParams 对 nil 写 NULL
  W := TDerWriter.Create;
  try
    // RFC 4055 3.1:[0] 哈希,[1] 带自身哈希的 MGF1,[2] 盐。
    // [3] trailerField = 1 等于它的 DEFAULT,X.690 11.5 因此省略它
    Result := W.Sequence(Concat3(
      W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
      W.ContextSpecific(1, W.Sequence(ConcatBytes(
        W.OID('1.2.840.113549.1.1.8'),             // id-mgf1
        W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
      W.ContextSpecific(2, W.IntegerOf(32), True)));
  finally
    W.Free;
  end;
end;

TDerWriter.AlgId 写带 NULL 参数的 AlgorithmIdentifier,RFC 4055 §2.1 正是这么要求编码器为嵌套的 SHA-256 标识生成的。产出的字节短到可以肉眼核对:外层 30 34,然后 A0 0F 包着 15 字节的 SHA-256 AlgorithmIdentifier,A1 1C 包着 28 字节的 MGF1 AlgorithmIdentifier,盐值是 A2 03 02 01 20。你在 GetSignatureAlgorithmParams 里写的盐必须与 HSM 实际用的那把一致;如果服务只告诉你「PSS 配 SHA-256」而不说盐长,发货之前问清楚——AlgorithmIdentifier 是一份关于它的声明

trailerField 为什么是省略,而不是打成 [3] 标签?

X.690 §11.5 规定 DER 编码器不得编码取值等于其 DEFAULT 的组件。trailerField 的 DEFAULT 是 trailerFieldBC,也就是整数 1,而 RFC 8017 定义的 PSS 只用这个 trailer。于是对第一版的显然修法——把 W.IntegerOf(1) 换成 W.ContextSpecific(3, W.IntegerOf(1), True)——产出 A3 03 02 01 01,宽容的 BER 解码器接受它,严格的 DER 解码器有权拒绝它。值是对的;出现这件事本身不对

同一条规则也解释了另外三个字段为什么出现:SHA-256 不是 sha1,配 SHA-256 的 MGF1 不是 mgf1SHA1,32 不是 20。用 SHA-1 配 20 字节盐的签名方会把整块塌缩成一个空 SEQUENCE、30 00。这个形状会绊倒所有假定「无参数」永远拼作 05 00 的人:CMS 里的 id-RSASSA-PSS 参数字段是必需的,空 SEQUENCE 意为「全部默认」,而 NULL 不是合法的 RSASSA-PSS-params 取值

PDFium Component 中 PSS 参数上的 X.690 11.5 规则示意图:等于 DEFAULT trailerFieldBC 的 trailerField 必须缺席,因为打了标签的 A3 包装正是严格 DER 拒绝的那种编码,而 saltLength 32 不同于默认的 20、必须以 A2 出现
DER 存在的意义就是一个值只有一种编码,而等于默认值的组件已经有最短的编码了——根本不出现

BER 允许编码器把取默认值的组件写进去;DER 禁止,因为 DER 存在的意义就是一个值只有一种编码,而对一个有两种合法编码的结构做的签名,是可以抬杠的。参数块在这里躲不开 DER:它坐在 SignerInfo 外层的 signatureAlgorithm 里,并经 RFC 6211 的 cmsAlgorithmProtection 属性复制进已签名属性——那里每个字节都被签名覆盖

检查一份不是你构建的 CMS

cmsAlgorithmProtection 里那份拷贝抓不住打错标签的块。属性和外层 signatureAlgorithm 来自同一次调用,所以它们永远一致,结构跟自己比当然通过。真正能抓住问题的是逐字段读参数的解码器。PDFium Component 在 FPdfPades.pas 里的 InspectPadesSignatureAlgorithm 就是这么做的:只接受 A0 到 A3,各至多一次、按升序,对缺失的任何字段填入 RFC 4055 默认值,并把有效值报告在 TPadesRsaPssParameters 里。块里有一根裸 INTEGER 时 SignaturePss.Valid 为 False,随后 EvaluatePadesSignatureAlgorithm 把 PolicyStatus 设为 pcsInvalid。它还拒绝与 SignerInfo 摘要算法不一致的 PSS 哈希——那正是宽松回落到 sha1 会产出的东西

远程签名时这一点最要紧:外部服务返回一份做好的 CMS,CompletePadesRemoteSignature 把它嵌进准备好的 PDF。嵌入之前先查这个 blob。在这一刻拒绝它只花一次 API 调用;从客户的校验器那里知道,就要跟服务的运营方再来一轮重签:

uses
  System.SysUtils, FPdfPades;

// 你在 CompletePadesRemoteSignature 之前自己加的建议检查
function CheckRemotePssCms(const CmsDer: TBytes; out Why: string): Boolean;
const
  OID_RSASSA_PSS = '1.2.840.113549.1.1.10';
var
  Info: TPadesSignatureAlgorithmInfo;
  Values: string;
begin
  Result := False;
  Why := '';
  // nil:让检查器自己在 CMS 里定位签名方证书
  if not InspectPadesSignatureAlgorithm(CmsDer, nil, Info) then
  begin
    Why := 'CMS SignerInfo could not be parsed';
    Exit;
  end;
  if Info.SignatureAlgorithmOid = OID_RSASSA_PSS then
  begin
    if not Info.SignaturePss.Valid then
    begin
      Why := 'RSASSA-PSS-params does not decode as RFC 4055 3.1';
      Exit;
    end;
    // 有效值,含默认值:缺失的 A2 在这里显示为 20
    Why := Format('hash %s, MGF hash %s, salt %d', [
      string(Info.SignaturePss.HashAlgorithmOid),
      string(Info.SignaturePss.MaskGenHashAlgorithmOid),
      Info.SignaturePss.SaltLength]);
  end;
  if Info.PolicyStatus = pcsInvalid then
    Exit;   // Why 里仍留着解码出的值,供日志使用
  Values := Why;
  Result := VerifyCmsAlgorithmProtection(CmsDer, Why);
  if Result then
    Why := Values;   // 保护检查成功时会清掉 Why
end;

就算成功,也把 Why 字符串记进日志。SHA-256 哈希旁边一个 20 的盐长,是 A2 丢失的参数块的指纹——无论服务自己的日志怎么讲。不想写代码快速看一眼的话,openssl asn1parse -inform DER -in signature.p7s 会打印这棵树;在 id-RSSASSA-PSS OID 底下,你想看到的是 cont [ 2 ] 包着十六进制 20 的 INTEGER,而完全没有 cont [ 3 ]。那条 SEQUENCE 里的顶层 INTEGER,就是第一个代码块里那种错打标签的编码

这件事在 PSS 的整个故事里处在什么位置

参数编码是 PAdES 签名里 PSS 可能出错的三条互不相干的路径之一,把它们分开能缩短排查。macOS 上某把钥匙或较旧的系统可能不提供 PSS,后端随即改用 PKCS#1 v1.5 签名并声明配套的 sha256WithRSAEncryption;这个能力问题见用 macOS 钥匙串身份签 PAdES。PKCS#11 token 读 CK_RSA_PKCS_PSS_PARAMS 时的整数宽度可能与调用方写下的不同,这个 ABI 问题见CK_ULONG 与 PKCS#11 打包陷阱。第三条就是上面这条:钥匙愿意,token 算出正确的字节,描述它们的 DER 却不遵守 RFC 4055 §3.1

已签名属性 SET 有它自己版本的自洽盲区:一种顺序哈希、另一种顺序发出的 SET,在陌生校验器重算哈希之前都看着没事;见CMS signedAttrs 与 DER SET OF 排序。上面那个检查接入的「准备、远程签名、完成」循环,其两阶段协议和完成步骤拒绝什么,见用 HSM 远程签 PAdES

PDFium Delphi component 的签名后端是随源码出货的,所以 Windows、macOS、PKCS#11 和 OpenSSL 各单元里的 GetSignatureAlgorithmParams 函数体就在那里,供你逐字节读出、与你自己签名方的输出并排对比