技術文章

PDFium Delphi 的 RSASSA-PSS-params 編碼

PDFium Component 3.114.20 版修正了三個 PAdES 簽章後端的 RSASSA-PSS-params 編碼:Windows CNG、macOS Keychain 與 PKCS#11。RFC 4055 §3.1 為 RSASSA-PSS-params 的每一個欄位都指定了明確的 context-specific 標籤,從 [0] 到 [3],而這些後端卻把 saltLength 當成裸露的 universal INTEGER 輸出,同時又寫出一個等於其預設值的 trailerField。簽章位元組從頭到尾都是對的。描述它們的 AlgorithmIdentifier 不是,而光這樣就足以讓驗證器拒收這個簽章

令人頭大的部分是這個 bug 藏在哪裡。一份 CMS 簽章有兩半:密碼學運算,以及告訴驗證器這個運算是怎麼做的 ASN.1。前一半做對、後一半做錯,結果就是一份任何遵循規格的工具都無法與偽造品區分的文件。本文只談後半:RSASSA-PSS-params 必須怎麼標記、三個後端如何以同樣的方式弄錯,以及修正後的 DER 用 TDerWriter 的術語講起來是什麼樣子

為什麼驗證器會拒收一個位元組正確的 RSASSA-PSS 簽章?

因為 RSASSA-PSS 是唯一一種驗證器無法從簽章本身還原參數的 RSA 方案。PKCS#1 v1.5 的填充完全由 OID sha256WithRSAEncryption 決定,所以它的參數就是一個裸露的 NULL,沒有什麼能弄錯。PSS 則由一個雜湊函式、一個帶有自己雜湊的遮罩產生函式,以及一個鹽長度參數化,而 RFC 8017 §A.2.3 把這三者全都留給實作者決定。簽章者挑選它們,AlgorithmIdentifier 承載它們,而驗證器必須精確重現它們,EMSA-PSS-VERIFY 才可能開始跑

所以當 PDFium Component 以 SHA-256、SHA-256 之上的 MGF1 與 32 位元組的鹽簽章時,這三件事必須在這裡撐過 DER 編碼、並在另一套實作上撐過 DER 解碼。驗證器剖析不了的參數區塊,會在任何模指數運算發生之前就結束驗證。而驗證器以不同方式剖析的區塊更糟,因為 RFC 4055 §3.1 給 saltLength 的預設值是 20。一個跳過它不認得欄位的解碼器會落到那個預設值,用 20 位元組的鹽去對一個以 32 算出來的簽章跑 EMSA-PSS-VERIFY,然後回報簽章有問題,卻完全沒暗示問題出在中介資料而不是金鑰。這兩種結果都是 3.114.19 的編碼會產生的,取決於驗證器有多嚴格,而兩者都不會指向 AlgorithmIdentifier

RFC 4055 §3.1 對 RSASSA-PSS-params 究竟要求什麼

RFC 4055 §3.1 把 RSASSA-PSS-params 定義為一個含四個欄位的 SEQUENCE,每個欄位都帶著明確的 context-specific 標籤與一個 DEFAULT 值:

// RSASSA-PSS-params ::= SEQUENCE {
//   hashAlgorithm      [0] HashAlgorithm      DEFAULT sha1,
//   maskGenAlgorithm   [1] MaskGenAlgorithm   DEFAULT mgf1SHA1,
//   saltLength         [2] INTEGER            DEFAULT 20,
//   trailerField       [3] TrailerField       DEFAULT trailerFieldBC
// }

在 DER 裡,明確標記意味著每個欄位都被包在一個 constructed 的 context-specific TLV 裡——[0] 用 A0、[1] 用 A1、[2] 用 A2、[3] 用 A3——而值本身的 universal 編碼就嵌在裡面。每個欄位都要標記,正是因為每個欄位都因為有預設值而變成選用。沒有標籤的話,解碼器無法分辨一個只裝著單一 AlgorithmIdentifier 的 SEQUENCE 到底承載的是 hashAlgorithm 還是 maskGenAlgorithm,因為兩者都是 SEQUENCE 型別;有了標籤,標籤編號就指認了欄位,不管旁邊哪些欄位在不在。PDFium Component 輸出的值遵循 ETSI TS 119 312 §7 的設定檔——SHA-256、以 SHA-256 的 MGF1,以及等於摘要長度的鹽——而且與每一個平台簽章呼叫被告知的內容完全一致:給 NCryptSignHash 的 BCRYPT_PSS_PADDING_INFO 且 cbSalt 為 32、給 PKCS#11 機制的 CK_RSA_PKCS_PSS_PARAMS 且 sLen 為 32,以及 Security framework 裡那個 SHA-256 摘要簽章用的 PSS 演算法

PDFium Component 中依 RFC 4055 的 RSASSA-PSS-params 示意:hashAlgorithm A0、maskGenAlgorithm A1 與 saltLength A2 各帶明確的 context 標籤與 DEFAULT 值;ETSI 設定檔輸出 SHA-256、以 SHA-256 的 MGF1 與鹽 32;而 trailerField A3 等於 trailerFieldBC,所以 DER 整個省略它
每個欄位都要標記,正是因為每個欄位都因為有預設值而變成選用,所以不管編碼器省略了哪些鄰居,標籤編號都能指認欄位

三個後端如何犯了同樣的錯

3.114.19 的編碼替前兩個欄位加了標籤、把後兩個留成裸露的,而且在 TWinCmsSigner、TKeychainCmsSigner 與 TPkcs11CmsSigner 裡一模一樣。這種對稱不是巧合:三者都實作 FPdfCms.pas 的 ICmsSigner 介面,而它們的 GetSignatureAlgorithmParams 本體是照同一個模板寫的。那個模板長這樣:

// 3.114.20 之前:[0] 與 [1] 有標籤,[2] 與 [3] 沒有
Result := W.Sequence(Concat4(
  W.ContextSpecific(0, W.AlgId(OID_SHA256), True),
  W.ContextSpecific(1, W.Sequence(ConcatBytes(
    W.OID(OID_MGF1), W.AlgId(OID_SHA256))), True),
  W.IntegerOf(32),      // 該用 [2] EXPLICIT 的地方,寫成裸露的 INTEGER
  W.IntegerOf(1)));     // trailerField 等於 DEFAULT,必須不存在

走過那個 SEQUENCE 的解碼器看到 A0,讀出雜湊演算法,看到 A1,讀出遮罩產生函式,然後遇到 02 01 20。那是一個 universal INTEGER,而 RSASSA-PSS-params 裡沒有任何地方有未加標籤的 INTEGER 成員。嚴格的解碼器就此停下。寬鬆的會跳過不認得的元素,永遠找不到 A2,把 saltLength 指定成預設的 20,接著又撞上第二個多出來的 INTEGER 02 01 01,再碰一次同樣的問題。兩條路都到不了 32 位元組的鹽。共用模板在對的時候很有效率,在不對的時候也同樣有效率地一次錯三次,這就是為什麼修正在同一次提交裡落到全部三個單元,也是為什麼之後這三個方法本體在結構上仍然一模一樣。將來的後端應該從這其中一個把區塊複製過去,而不是重新推導,因為出錯的地方正是那段推導

PDFium Component 中 3.114.19 那個 DER bug 的示意:在 A0 與 A1 之後,參數在該放 A2 明確標籤的位置遇到裸露的 02 01 20,嚴格的解碼器就此停下,寬鬆的則以預設的 20 位元組鹽放行;而 3.114.20 把 32 位元組的鹽包進 A2
RSASSA-PSS-params 沒有未加標籤的 INTEGER 成員,所以那些多出來的位元組不是剖析失敗,就是默默退回預設鹽長度,兩條路都到不了簽章者的 32

為什麼 trailerField 要省略,而不是標成 [3]?

因為 X.690 §11.5 說 DER 編碼器不得編碼一個值等於其 DEFAULT 的元件,而 trailerField 的 DEFAULT 是 trailerFieldBC,也就是整數 1。對舊程式碼最顯而易見的修正——把裸露的 W.IntegerOf(1) 換成 W.ContextSpecific(3, W.IntegerOf(1), True)——產生的區塊是寬鬆的 BER 解碼器會接受、嚴格的 DER 解碼器有權拒收的。值本身沒錯。錯的是它出現。同一條規則也正是另外三個欄位必須出現的原因:SHA-256 不是預設的 sha1、以 SHA-256 的 MGF1 不是預設的 mgf1SHA1,而 32 不是預設的 20。假如這個後端以 SHA-1 與 20 位元組的鹽簽章,RFC 4055 §3.1 會把參數收斂成一個空的 SEQUENCE 30 00,而驗證器期待的是那個空 SEQUENCE,不是 NULL。PDFium Component 從不輸出那個形狀,因為它從不用那些值簽章,但正是這個情況會逮到任何以為「沒有參數」一律拼成 05 00 的人

這就是 DER 與 BER 之間那個對簽章特別要緊的區別。BER 允許編碼器放進一個帶預設值的元件;DER 禁止,因為 DER 存在的目的就是讓一個值只有恰好一種編碼,而對一個有兩種合法編碼的結構做簽章,就是一個可以被爭論的簽章。CMS 的 signedAttrs 裡一切東西都是 DER,正是這個緣故;而參數區塊除了出現在外層的 signatureAlgorithm,也透過 cmsAlgorithmProtection 屬性在 signedAttrs 裡旅行,所以它沒有豁免權

PDFium Component 中 PSS 修正裡 X.690 11.5 規則的示意:trailerField 等於它的 DEFAULT trailerFieldBC,必須保持不存在,因為加了 A3 外層正是嚴格 DER 會拒收的編碼;而 saltLength 為 32、不同於預設的 20,必須以 A2 出現
DER 存在的目的就是讓一個值只有恰好一種編碼,而等於預設值的元件早就有最短的編碼了,那就是根本不出現

修正後的 TDerWriter 編碼

PDFium Component 現在用三次 FPdfAsn1.pas 的 TDerWriter.ContextSpecific 呼叫來建出參數,每個非預設欄位一次,每次的 Constructed 參數都設為 True 以產生明確標籤的外層,而 trailer 欄位一行都沒有。以下是把 OID 寫出來的 TWinCmsSigner.GetSignatureAlgorithmParams 本體;Keychain 與 PKCS#11 那兩個單元用 OID_SHA256、OID_MGF1 與 OID_RSASSA_PSS 拼出同樣的值:

function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
  W: TDerWriter;
begin
  if FPaddingScheme = psRsaPss then
  begin
    W := TDerWriter.Create;
    try
      // RFC 4055 3.1 為全部四個欄位加標籤。saltLength 是 [2];
      // 這裡放裸露的 INTEGER 會被讀成另一個欄位的開始。
      // trailerField 是 [3]、DEFAULT 為 1,而 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
  else
    Result := nil;   // PKCS#1 v1.5 與 ECDSA:AlgIdWithParams 寫入 NULL
end;

周圍機制有兩個細節要緊。TDerWriter.AlgId 產生一個參數為 NULL 的 AlgorithmIdentifier,那正是 RFC 4055 §2.1 要編碼器為巢狀的 hashAlgorithm 與 MGF1 內層雜湊所產生的東西。而 FPdfCms.pas 裡的 CMS 建置器透過 TDerWriter.AlgIdWithParams 把簽章 OID 與這些位元組配成一對,後者在參數為 nil 時會代換成 NULL;這就是為什麼 psRsaPkcs1v15 與 psEcdsa 直接回傳 nil、也從來沒受影響,以及為什麼 1.2.840.113549.1.1.10(id-RSASSA-PSS)是三者中唯一帶著真正參數區塊的簽章 OID。SHA-256 設定檔產生出來的位元組是固定的,而且短到可以用眼睛檢查:一個外層 30 34 SEQUENCE,裡面是 A0 0F 包著 15 位元組的 SHA-256 AlgorithmIdentifier、A1 1C 包著 28 位元組的 MGF1 AlgorithmIdentifier(它自己的參數就是同一個 SHA-256 AlgorithmIdentifier),以及鹽的 A2 03 02 01 20。如果您把 signatureAlgorithm 傾印出來,看到 02 01 20 出現在參數 SEQUENCE 的頂層、而不是在 A2 裡面,那您看到的就是 3.114.19 的編碼

為什麼測試套件沒抓到畸形的 AlgorithmIdentifier?

因為 PAdES 測試是透過一個假簽章者驅動 CMS 建置器,它回報 sha256WithRSAEncryption、並從 GetSignatureAlgorithmParams 回傳 nil,所以 PSS 參數區塊在測試裡根本沒被建出來過。對必須在沒有憑證存放區、Keychain 或權杖的情況下執行的測試來說,那是合理的設計,而它有個形狀精確的盲點:只有真後端才會產生的東西,也只有真後端會跑到。第二層更有意思。PDFium Component 也會把簽章 AlgorithmIdentifier(含參數)放進 RFC 6211 的 cmsAlgorithmProtection 簽章屬性裡,而驗證器會把那一份與外層的 signatureAlgorithm 相比。兩份都來自同一次呼叫,所以它們完美相符,每一項內部一致性檢查都通過。這份編碼自洽而錯誤,屬於再怎麼拿結構跟自己比都比不出來的那一類 bug;同樣的教訓、不同的結構,寫在 CMS signedAttrs 與 DER SET OF 排序一文裡——那裡一個 SET 以一種順序雜湊、以另一種順序輸出,看起來一切正常,直到外來的驗證器重算雜湊

真正抓得到這類 bug 的,是拿一個不是編碼器作者寫的解碼器,去跑真後端的實際輸出。PDFium Component 在 Windows 上的驗證路徑走 CryptoAPI,而不是函式庫自己的讀取器,那邊一個被拒收的 PSS 簽章,就是一路追回參數的起點。任何實作為了讓其他實作讀取而輸出的 ASN.1,都值得至少過一次它不能控制的解碼器;而結構裡的預設值與標籤越多,那一趟的價值就越高

這件事擺在 PSS 整體故事的哪個位置

這項修正與 PSS 在 PAdES 簽章裡另外兩個可能出錯的地方彼此獨立,把它們分開來看可以縮短除錯時間。macOS 後端可能發現某把特定的金鑰或某個較舊的系統拒用 PSS,於是降級成 PKCS#1 v1.5,而 AlgorithmIdentifier 必須跟著降級走;那是能力問題,見用 macOS Keychain 身分簽 PAdES一文。PKCS#11 後端可能交給權杖一個 CK_RSA_PKCS_PSS_PARAMS,而權杖因為整數寬度不符而以不同方式解讀它的佈局;那是 ABI 問題,見CK_ULONG 與 PKCS#11 的封裝陷阱一文。本文談的是第三種失敗:金鑰願意、權杖算出來的位元組也對,而描述那個結果的 DER 不符 RFC 4055 §3.1

一個宣告使用 PSS 的簽章者,承擔了一項 v1.5 簽章者從來沒有的義務:用一種能讓另一套實作解碼成同樣三個值的形式,描述自己的參數。RFC 4055 §3.1 定下標籤、X.690 §11.5 定下哪些欄位可以出現、ETSI TS 119 312 §7 定下值得選的值。PDFium Delphi component 的三個後端都以原始碼出貨,所以上面那段 GetSignatureAlgorithmParams 本體是您可以讀、可以傾印、可以拿去跟自己的驗證器比對的東西,而不是只能相信的宣告