技術文章

PDFium 中 CMS signedAttrs 的 DER SET OF 排序

PDFium Component 透過 TDerWriter.SetOfSorted 建 CMS 的 signedAttrs 區塊,因為 X.690 第 11.6 條要求 SET OF 的成員依各自 DER 編碼的升序輸出。改用建構順序組裝的話,你得到的是自己驗證器接受、每個第三方 PAdES 驗證器都拒絕的簽名

最後那句話正是這個缺陷能躲過整套回歸測試的全部原因。簽署路徑有覆蓋、驗證路徑有覆蓋、每個平台的往返都是綠的,輸出卻仍然不是有效的 DER。用你的寫入端簽署、用你的讀取端驗證的測試,偵測不到對編碼規則的偏離,因為兩半共享同一個偏離

X.690 第 11.6 條實際要求什麼

SET OF 值的成員必須排序,排序鍵是每個成員的完整 DER 編碼,以無號八位元字串比較,當一個是另一個的前綴時較短的編碼排前面。這是規範性限制而不是建議:DER 對給定值只允許一種編碼,所以未排序的 SET OF 不是同一個值的另一種排序編碼,它根本不是 DER 編碼。因為 ASN.1 tag 在 TLV 裡排最前面,比較通常在前兩個八位元內就分出勝負;又因為 CMS 屬性清單的每個成員都以 30——SEQUENCE tag——開頭,平手由長度八位元決定。因此要求的順序看起來幾乎像按屬性大小排序,這在你盯著 hex dump 納悶順序為什麼看起來隨機時值得知道

PDFium Component 文章圖示,說明 X.690 第 11.6 條如何排序 CMS signedAttrs SET OF:BuildSignedAttrs 依建構順序追加 content-type、message-digest、signing-certificate-v2 與 cms-algorithm-protection,而合規的 DER 依完整編碼排序四個成員:30 18、30 2D、30 2F、30 37
建構順序與編碼順序從第一份簽名起就不一致,因為四個成員都帶 SEQUENCE tag 30,由長度八位元決定比較結果

為什麼最小的屬性集就已經失序?

因為自然的建構順序與編碼順序從你產出的第一份簽名起就不一致,而且不涉及任何選用屬性。FPdfCms.pas 的 BuildSignedAttrs 依序追加 content-type、message-digest、signing-certificate-v2、cms-algorithm-protection。用 SHA-256 digest 與 RSA 簽名演算法的話,四個屬性的 TLV 以這些位元組開頭

// BuildSignedAttrs 產生的建構順序
//   content-type              30 18   (24 個內容八位元)
//   message-digest            30 2F   (47)
//   signing-certificate-v2    30 37   (55)
//   cms-algorithm-protection  30 2D   (45)
//
// X.690 11.6 要求的 DER 順序
//   30 18  content-type
//   30 2D  cms-algorithm-protection
//   30 2F  message-digest
//   30 37  signing-certificate-v2

四個屬性裡有兩個要動。30 2D 的 cms-algorithm-protection 必須排在 30 37 的 signing-certificate-v2 前面,也必須排在 30 2F 的 message-digest 前面。所以 PDFium Component 能產出的最小合規 PAdES B-B 屬性集,在舊程式碼下就已經失序了——還沒有人加上 signing-time、commitment-type-indication 或 signature-policy-identifier。從來不存在一個這個 bug 不會觸發的組態

為什麼自己的測試全過,外部驗證器卻全部失敗?

因為兩族驗證器對不同的位元組計算 message digest。RFC 5652 第 5.4 條說簽名運算的輸入是 SignedAttributes 值的 DER 編碼,IMPLICIT [0] tag 換成 SET tag 31,而決定一切的關鍵詞是DER 編碼,不是你收到的八位元。經由 CryptVerifyDetachedMessageSignature 到達的 Windows 平台驗證器拿收到的八位元、換掉開頭 tag、照原樣雜湊,所以順序從來不進它的事,失序的 SET OF 雜湊出來正好等於簽署者算的值。OpenSSL 與 BouncyCastle——歐洲那些廣為部署的驗證器就是建在它們上面的——把屬性集剖析成結構、雜湊前重新編碼。重新編碼套用了排序,digest 不再吻合簽署時的那個,簽名被回報為壞掉而不是編碼錯誤,於是你為了活在一個比較函式裡的缺陷,翻遍金鑰材料與憑證鏈

PDFium Component 圖示排序 SET OF bug 背後的驗證器分歧:簽署者以 SET tag 31 雜湊 DER 編碼,Windows CryptVerifyDetachedMessageSignature 路徑對收到的八位元做 digest 所以順序無關、失序的簽名照樣通過,而 OpenSSL 與 BouncyCastle 重新編碼、套用 X.690 排序後回報簽名壞掉
CryptVerifyDetachedMessageSignature 對收到的八位元原樣做 digest,所以失序的集合照樣驗證通過;OpenSSL 與 BouncyCastle 則在雜湊前重新編碼,把簽名回報為壞掉而不是編碼錯誤

把視野拉出這一個 bug,因為那才是最貴的部分。同源的往返對你程式碼兩半共享的、對互通性規格的任何偏離都免疫;它證明的只是你的寫入端與讀取端彼此一致,而那根本不是大家在乎的性質。對任何以線路格式離開程序的東西,唯一有意義的回歸是把位元組釘在規格上,或拿外來實作跑你的輸出。OpenSSL CMS 驗證後端在這裡占一席之地,除了可攜性還有第二個理由:它是你自家 DER 的真正獨立讀取端

修法,以及它原本就存在的地方

DER 寫入端一直都有排序版本。FPdfAsn1.pas 的 TDerWriter 暴露兩種方法:SetOf 把預先串接好的內容緩衝區包進 SET tag、保留交給它的任何順序;SetOfSorted 以 TDerByteArrays 逐個收成員、包裝前依編碼合併排序。FPdfMac.pas 的 PDF MAC 程式碼早就用 SetOfSorted 建它的認證屬性集;只有 CMS 簽署者沒有。把成員以陣列形式一路保持到包裝呼叫,是修法的結構半邊,因為屬性一旦串進單一緩衝區,排序資訊就沒了,下游再怎麼小心都救不回來。對 SET OF 只提供 SetOf(Content: TBytes) 的寫入端 API,就是一個邀請這個 bug 的 API;收陣列的多載才是讓正確的事變得可表達的關鍵

PDFium Component 的 TDerWriter API 組合圖示:SetOf 包住預先串接的 TBytes 緩衝區、保留交給它的成員順序,產出不是 DER 的輸出;SetOfSorted 把成員保持為 TDerByteArrays 陣列、依完整編碼合併排序、包出合規的 SET OF
屬性一旦串進單一緩衝區,排序資訊就沒了,所以收陣列的 SetOfSorted 多載才是讓正確的事在編碼當下得以表達的關鍵
// 之前:成員維持 AddAttr 依序附加的順序
Result:= W.SetOf(ConcatMany(AttrList));

// 之後:在編碼當下套用 X.690 11.6 排序
Result:= W.SetOfSorted(AttrList);

SET OF 要排序,SEQUENCE OF 不用

這是最常被搞混的區別,搞反它跟忽略它一樣有破壞力。SEQUENCE OF 是有序集合:順序是值的一部分,你絕不能排序。SET OF 作為值是無序的,所以 DER 施加規範順序讓編碼唯一。排序 SEQUENCE OF 會毀掉資料;讓 SET OF 保持未排序則會產出不是 DER 的東西

  • SET OF,必須排序:SignerInfo 裡的 SignedAttributes 與 UnsignedAttributes、digestAlgorithms 集合、SignedData 裡的 certificates 與 crls 集合、signerInfos 集合、每個個別 Attribute 內部的值集合,以及 X.501 Name 裡每個 RelativeDistinguishedName 內部的屬性集合
  • SEQUENCE OF,絕不排序:組成 Name 的 RDN 清單、SigningCertificateV2 的 certs 欄位、延伸清單,以及它們裡面的 general-name 清單

RDN 是坐在上一層 SEQUENCE OF 裡面的 SET OF,所以一個 distinguished name 在兩層巢狀內就同時包含兩條規則。這個版面也解釋了為什麼單成員集合如此寬容:只有一個元素的集合當然是排序好的,這就是 BuildSignedAttrs 裡用 W.SetOf 建的屬性值集合照原樣就正確、不需要改動的原因。bug 只存在於集合真的裝著兩個或更多成員的地方,而多值 RDN 夠罕見,所以大多數名稱編碼器犯同樣的錯好幾年都沒事

怎麼防止它捲土重來?

對位元組斷言,不要對結論斷言。最便宜又持久的檢查是走自己輸出的自我稽核:剖析發出的 signedAttrs,用寫入端同一個無號八位元字串比較把每個成員跟前一個比,任何一對失序就讓建置失敗。它微秒級跑完、不需要外部函式庫,而且不像驗證往返,它測試的是規格實際陳述的性質

// 走訪 SET OF 的成員,確認符合 X.690 11.6 排序。
// Members 是傳給 SetOfSorted 的清單,或是
// 重新剖析已產生的 31 xx / A0 xx 區塊
function SetOfIsSorted(const Members: TDerByteArrays): Boolean;
var
  I: Integer;
begin
  for I:= 1 to Length(Members)- 1 do
    if CompareDerBytes(Members[I- 1], Members[I])> 0 then
      Exit(False);
  Result:= True;
end;

// 在簽章測試中,別只停在「我們的驗證器說有效」就收手。
// 釘住簽章者實際雜湊的那些位元組
Attrs:= BuildSignedAttrs(W, Digest, CertHash, DigestOid, SigOid, nil, Options);
Assert(Attrs[0]= $31, 'signedAttrs must carry the SET tag when hashed');
Assert(SetOfIsSorted(SplitSetOfMembers(Attrs)), 'signedAttrs not in DER order');

有兩條界線值得直說。排序 signedAttrs 本身不會讓簽名互通,因為容器裡其他一切也得站得住,而驗證器拒絕其他方面都正常的 PAdES 簽名的理由,多到可以單獨寫一篇。而且排序 SET OF 是「你寫的東西」的性質,不是「你讀的東西」的性質:PDFium Component 仍然必須接受 signedAttrs 未排序的進站簽名,因為這種檔案在野外存在,RFC 5652 第 5.4 條也叫驗證器重新編碼而不是拒絕。輸出嚴格、輸入寬容,一如往常。DER 寫入端、CMS 建置器與PAdES B-B 逐步教學描述的簽署路徑,全部以可讀的 Pascal 原始碼隨PDFium Delphi component提供——這正是簽署函式庫附原始碼的全部意義:當歐洲某處的驗證器不同意你的文件時,你能讀到你程式碼發出的確切位元組、以及它們違反的確切條款,而不是開一張單子等回覆