技術文章

PDFium Delphi 的 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 簽章,驗證器為什麼還是拒收

因為 RSASSA-PSS 是唯一一個驗證器無法從演算法 OID 推出參數的 RSA 方案。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,每個都帶明確的 context-specific 標記與 DEFAULT 值。在 DER 裡,明確標記把每個欄位包進一個結構化的脈絡 TLV——A0 到 A3——欄位自己的 universal 編碼則巢在裡面:

欄位標記(DER 位元組)DEFAULTETSI TS 119 312 §7 值是否編碼
hashAlgorithm[0](A0)sha1SHA-256是
maskGenAlgorithm[1](A1)mgf1SHA1MGF1 with 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、MGF1 with SHA-256 與鹽值 32,而 trailerField A3 等於 trailerFieldBC,DER 乾脆省略
每個欄位都帶標記,恰恰因為每個欄位都靠預設值成為可選,所以標記號碼能指名欄位,無論編碼器省略了哪些鄰居

把鹽選成等於摘要長度,然後固定在那裡。有些 API 提供「最大鹽值」或「鹽長度自動」,得到的值取決於金鑰大小;這在 RFC 8017 下合法,但 AlgorithmIdentifier 接著就得寫出一個隨金鑰而變的數字,而 ETSI TS 119 312 §7 反正要的就是 SHA-256 配 32 位元組鹽。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——一個 universal 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 with 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,MGF1 with SHA-256 不是 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-RSASSA-PSS OID 底下你該看到 cont [ 2 ] 包著十六進位 20 的 INTEGER,而且完全沒有 cont [ 3 ]。那個 SEQUENCE 裡的頂層 INTEGER,就是第一段程式碼的錯標編碼

這件事在 PSS 故事裡的位置

參數編碼是 PSS 在 PAdES 簽章裡三種各自獨立的翻車方式之一,把它們分清楚能縮短除錯。macOS 上特定的金鑰或較舊的系統可能不提供 PSS,後端於是改用 PKCS#1 v1.5 簽署並宣告 sha256WithRSAEncryption 以求一致;這個能力問題在以 macOS Keychain 身分簽署 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 本體就在那裡,可以讀、可以轉儲,與你自己簽署器的輸出逐位元組對照