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 納悶順序為什麼看起來隨機時值得知道
為什麼最小的屬性集就已經失序?
因為自然的建構順序與編碼順序從你產出的第一份簽名起就不一致,而且不涉及任何選用屬性。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 不再吻合簽署時的那個,簽名被回報為壞掉而不是編碼錯誤,於是你為了活在一個比較函式裡的缺陷,翻遍金鑰材料與憑證鏈
把視野拉出這一個 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;收陣列的多載才是讓正確的事變得可表達的關鍵
// 之前:成員維持 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提供——這正是簽署函式庫附原始碼的全部意義:當歐洲某處的驗證器不同意你的文件時,你能讀到你程式碼發出的確切位元組、以及它們違反的確切條款,而不是開一張單子等回覆