技術文章

別再倒退走 DER 長度位元組:PDFium Component

PDFium Component 一律從內容長度定位巢狀 CMS 結構的起點,絕不倒退走過長度位元組,因為緊接在內容之前的那個位元組是最後一個長度位元組,它對前面還有幾個位元組一無所知。FPdfCms.pas 裡的 CmsHeaderStart 改用 ContentLen 推算標頭長度——DER 讓這件事可以算得精確——這正是讓 AddSignatureTimestampToCms 不會弄壞每一份憑證集合長於 127 位元組的 CMS 的原因

場景是 PAdES B-T 升級。簽章時戳屬性——ETSI EN 319 122-1 條款 5.3 以 OID 1.2.840.113549.1.9.16.2.14 定義的那一個——必須落進 RFC 5652 條款 5.3 所述的 SignerInfo 之 unsignedAttrs 裡,而依定義它只能在簽章值存在之後才加入,因為時戳權杖是對那個值算出來的。所以權杖到達時,CMS 早就建好、也早就簽好了。加入一個屬性會改變 SignerInfo 的長度,連帶改變 signerInfos SET 的長度,然後是 SignedData、然後是 [0] EXPLICIT 外層、然後是最外層的 ContentInfo。每一個外層標頭都得重新輸出,而不在這條路徑上的一切都得逐位元組搬過去。B-LT 與 B-LTA 逐步解說談權杖替您換到什麼;本文談的是重建過程一直弄錯的那四個位元組——就擺在憑證集合前面

為什麼加入時戳會需要兄弟節點的標籤位移?

因為重建時要逐字重用 signerInfos SET 的四個兄弟節點,而讀取器回報的是它們的內容在哪裡,不是它們的標籤在哪裡。TDerReader.ReadTlv 交回標籤位元組、內容位移、內容長度與下一個 TLV 的位移。這對於往結構裡下降是對的介面,但要複製整個元素,您需要的是它的標籤所在的那個位元組,而呼叫端手上只有 ContentOffs。CmsSliceTlv 就是為了搭起這道橋:給定內容位移與長度,它把標籤、長度位元組與內容當成單一緩衝區回傳,而 AddSignatureTimestampToCms 會對 contentType OID、version INTEGER、digestAlgorithms SET、encapContentInfo SEQUENCE,以及存在時的 certificates [0] 集合呼叫它

// 在 AddSignatureTimestampToCms 裡:往下降,把兄弟節點逐字切出來
if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> Byte(asnSequence)) then
  raise Exception.Create('CMS: encapContentInfo SEQUENCE expected');
SdEncapTlv:= CmsSliceTlv(CmsDer, CO, CL);
R.Position:= CN;
// optional certificates [0]
HasCerts:= (R.Position< SdEnd) and (CmsDer[R.Position]= $A0);
if HasCerts then
begin
  if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> $A0) then
    raise Exception.Create('CMS: certificates [0] malformed');
  SdCertsTlv:= CmsSliceTlv(CmsDer, CO, CL);   // 標籤 + 長度位元組 + 內容
  R.Position:= CN;
end;

這五個切片裡有四個很小:十一位元組的 OID、三位元組的 INTEGER、十七位元組的摘要演算法集合、十三位元組的分離式 encapContentInfo。憑證集合則是那個帶著簽章憑證與其鏈的切片,而一張真正的 X.509 憑證至少也有幾百位元組。因此憑證集合是唯一一個長度位元組會用長形式的切片,也正是舊 helper 定位不到的那一個

在 CMS 裡加入 PAdES B-T 時戳會重建什麼:CmsSliceTlv 逐位元組複製 contentType、version、digestAlgorithms、encapContentInfo 與憑證集合;憑證集合是唯一長到要離開短長度形式的切片,而從 SignerInfo 一路到 ContentInfo 的每一個外層標頭都會重新輸出
被簽署的部分在結構上完全沒被動到,因為從 SignerInfo 前綴到簽章 OCTET STRING 都是逐字複製的,所以會重新摘要 signedAttrs 的驗證器,在時戳落地前後看到的是相同的位元組

為什麼 DER 的長度位元組不能倒退走?

因為長度位元組的數量就存在第一個位元組裡,而從內容往回讀時,您先遇到的是最後一個。X.690 條款 8.1.3.4 定義短形式:一個位元組,bit 8 為 0,bits 7 到 1 承載 0 到 127 的長度。條款 8.1.3.5 定義長形式:第一個位元組 bit 8 為 1,其 bits 7 到 1 給出後續位元組的數量,後面跟著那些以無號大端整數承載長度的位元組。這條規則裡沒有任何東西把後續位元組標記成後續。它的 bit 8 跟其他位一樣只是數值位,所以一個測試 Buf[ContentOffs- 1] 最高位的倒退走訪,其實是在測一個資料位,然後把它的低七位當成數量來讀

// 舊 helper,只拿到內容位移
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
  P, LenByte, LongLen: Integer;
begin
  P:= ContentOffs- 1;            // 落在最後一個長度位元組上
  if P< 0 then
    Exit(ContentOffs);
  LenByte:= Buf[P];
  if (LenByte and $80)= 0 then   // 只對第一個位元組有意義
    Result:= P- 1
  else
  begin
    LongLen:= LenByte and $7F;
    Result:= P- LongLen- 1;
  end;
end;

// 一個 1500 位元組憑證集合的標頭:  A0 82 05 DC
//   Buf[ContentOffs- 1]= $DC  -> bit 8 為 1,$DC and $7F= 92
//   Result= ContentOffs- 94     (標籤在 ContentOffs- 4)

拿一個裝著 1500 位元組憑證的憑證集合標頭 A0 82 05 DC 來看。走訪落在 DC,看到最高位被設起,從低七位取出 92,回報標籤在內容之前 94 位元組,實際上它只在前 4 位元組。在一份由 BuildSignedData 建出的 SignedData 裡,憑證集合的內容只落在 CMS 開頭幾十個位元組處,所以算出來的位移不只是提早,而是負的,而舊程式碼防的是 ContentOffs- 1 掉到零以下,不是防它的最終結果。接著 CmsSliceTlv 就切出一段比元素長九十幾個位元組、而且從緩衝區之前就開始的片段,重建後的 SignedData 把那段片段放在它憑證集合該在的位置。三個位元組的長度只要最後一個位元組剛好低於 $80——比如 A0 82 05 10——就會以另一種方向失敗:走訪把它當成短形式位元組,於是把切片起點放在 05,晚了兩個位元組、還落在長度位元組裡面,而且完全沒有標籤。兩種方向的結果都是錯的,差別只在方向

為什麼 DER 的長度位元組不能倒退走:從尾端讀 A0 82 05 DC 會落在最後一個位元組 DC,它被設起的最高位給出一個荒謬的數量 92,把標籤擺到早了 94 個位元組的位置;而 A0 82 05 10 則以另一種方式失敗,把切片起點擺在長度位元組裡、晚了兩個位元組
舊的 CmsHeaderStart 防的是中間那次減法,而不是它的最終結果,所以切片甚至可能從緩衝區之前就開始,而重建後的 SignedData 就把那段片段放在憑證集合該在的位置

DER 保證了什麼,讓正向推算可以精確?

DER 保證長度編碼是長度的純函式。X.690 條款 10.1 把 DER 限制在確定形式,並要求最少位元組數,這就移除了 BER 允許的兩項自由:不定形式,以及用前導零位元組把長形式長度補齊。在那條規則下,小於 128 的內容長度恰好有一個長度位元組,而其他任何長度則是一個初始位元組,加上恰好等於該長度所需有效位元組數的後續位元組。CmsHeaderStart 的呼叫端手上已經有 ContentLen,因為 ReadTlv 剛把它交回來,所以標頭長度不看緩衝區裡任何一個位元組就能算出來

DER 保證的正向推算:內容長度 127 編成 A0 7F、128 編成 A0 81 80、255 編成 A0 81 FF、256 編成 A0 82 01 00、1500 編成 A0 82 05 DC,所以標頭長度單憑 ContentLen 就推得出來,而 TryReadTlvAt 早就已經拒收了每一種非最小的 BER 形式
用 32 位元組與 64 位元組憑證建出的測試素材始終留在短形式裡,而倒退走訪在那種情況下只是碰巧答對;這就是為什麼邊界測試套件現在要跨過 127、128、255 與 256 位元組
// 出貨的 helper:從內容長度推算標頭。
// 依 X.690 10.1,長度位元組是 ContentLen 的函式
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
  LengthOctets, Remaining: Integer;
begin
  if ContentLen< 128 then
    LengthOctets:= 1                 // 短形式,X.690 8.1.3.4
  else
  begin
    LengthOctets:= 1;                // 初始位元組,X.690 8.1.3.5
    Remaining:= ContentLen;
    while Remaining> 0 do
    begin
      Inc(LengthOctets);             // 每個有效位元組一個
      Remaining:= Remaining shr 8;
    end;
  end;
  Result:= ContentOffs- LengthOctets- 1;
  if Result< 0 then
    Result:= ContentOffs;
end;

有兩個細節讓這件事安全,而不只是聽起來合理。第一,輸入是 DER 這個假設在上游就被強制:TDerReader.TryReadTlvAt(ReadTlv 建在它上面)會拒收不定形式、拒收第一個後續位元組為零的長形式長度,也拒收單一後續位元組低於 $80 的情況。能走到 CmsSliceTlv 的 TLV 早就過過這些檢查,所以 BER 式非最小長度到不了那段推算、也就不能讓它說謊。第二,負結果的後備現在防的是真正的答案,不是中間值。值得一提的是,讀取器一直都知道標籤位移:TDerTlv 同時帶著 Offset 與 HeaderLength,只有四輸出參數的 ReadTlv 介面把它們丟掉了。把它們回傳會是更乾淨的長期介面;出貨的修正保持那個介面不動,讓 helper 靠自己的力量正確

為什麼時戳測試在有這個 bug 的情況下還是通過?

因為每一張素材憑證都短到用短形式,而倒退走訪正好在那種情況下是對的。Tests.PadesTimestamp.pas 在其中一個測試裡用 SetLength(SignerCertDer, 32) 造出簽章憑證,另一個用 64,內容填一段位元組斜坡。32 位元組的憑證集合編成 A0 20,64 位元組的編成 A0 40,各只有一個長度位元組。從內容往回走就落在那一個位元組上,它的最高位是 0,因為它就是第一個、也是唯一一個長度位元組,於是 helper 因為錯的理由給了對的答案。1414 個案例的套件全綠、帶時戳的 CMS 剖析得過、第一階段驗證器回報 B-T,而這每一項檢查都是對著一份真實文件從來不曾有過的憑證集合跑的

有用的部分是那條通則。只要某條程式路徑取決於長度怎麼編碼,測試素材就必須跨過那個編碼邊界,而對 DER 來說,那意味著內容要長於 127 位元組(逼出長形式),最好也長於 255 位元組(逼出第二個後續位元組)。同樣的紀律也適用於那次審查裡另一個自我驗證看不到 DER 偏差的案例:在 signedAttrs 裡未排序的 SET OF對同源的 round trip 也是隱形的,理由在結構上完全相同——測試只跑過那些錯誤程式碼與正確程式碼會得出相同結果的輸入。下面的片段直接呼叫切片 helper,這表示測試組建要把它從 FPdfCms.pas 匯出;同樣的邊界也可以走公開介面到達——把每一種大小的鏈憑證交給 BuildSignedData,再重新剖析帶時戳的結果

// 釘住邊界:穿過長形式標頭的切片必須從標籤開始
const
  Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);

procedure TCmsSliceTests.HeaderStart_LongFormLengths;
var
  W: TDerWriter;
  Content, Tlv: TBytes;
  I, Len: Integer;
begin
  W:= TDerWriter.Create;
  try
    for I:= Low(Lens) to High(Lens) do
    begin
      Len:= Lens[I];
      SetLength(Content, Len);
      Tlv:= W.Wrap($A0, Content);            // A0 7F / A0 81 80 / A0 82 05 DC ...
      W.Clear;
      // 內容緊接在標頭之後開始;切片必須是整個 TLV
      Assert.AreEqual(Length(Tlv),
        Length(CmsSliceTlv(Tlv, Length(Tlv)- Len, Len)),
        'slice through header of a '+ IntToStr(Len)+ '-byte content');
    end;
  finally
    W.Free;
  end;
end;

重建仍然在哪裡劃下界線

AddSignatureTimestampToCms 是為 BuildSignedData 產出的那種 CMS 寫的,它的界限也由此而來。這段走訪預期只有一個 SignerInfo,也只重新輸出那一個,所以一份外來的多重簽章者 CMS 回來會只剩一個簽章者;它認得選用的 certificates [0] 集合,卻不認得 crls [1] 集合,而帶著後者的 CMS 會用 signerInfos SET expected 例外大聲失敗,而不是安靜地切錯。新的 unsignedAttrs 只裝一個屬性,所以 X.690 條款 11.6 的 SET OF 排序規則自然滿足、不需要排序。而被簽署的部分在結構上完全沒被動到:從 SignerInfo 前綴到簽章 OCTET STRING 都是逐字複製的,這就是為什麼會重新摘要 signedAttrs 的驗證器,在時戳加入前後看到相同的位元組。當它還是把文件打回時,原因通常在別處,值得另一份檢查清單

DER 讀取器、寫入器、CMS 建置器與這個時戳注入,全都以 Pascal 原始碼隨 PDFium Delphi component 出貨,而這種形狀的 bug 正是支持這麼做的理由:當重建出來的 SignedData 多出九十幾個位元組時,您要讀的是切出那段片段的 helper 與它誤讀的 X.690 條款,不是一個黑盒子的堆疊追蹤