PDF Library for Delphi(PDFlibPas)抽出一枚 PDF 簽章裡的憑證,靠的是對 /Contents 內 CMS SignedData 的純 DER 走訪,過程完全不碰 CryptoAPI。從 v3.539.10 起,每個巢狀讀取都以父元素為邊界,CMS 之後的零填充照 CMS 宣告的長度裁切,OID 則把合併後的第一個子識別碼以 base-128 編碼。邊界規則與 OID 修正換掉的程式碼,都屬於「不報錯卻給出錯誤答案」那種;填充規則則是讓變嚴格的讀取器不會把真實世界的簽章拒於門外
讀取這一面比表面看起來更要緊。長期驗證工具得先從既有簽章裡撈出簽署者憑證與其上層簽發者,才有辦法取得廢止資料;稽核報告得說清楚是誰簽的;而 Linux 上的 Lazarus 建置沒有任何 Windows 訊息函式可以靠。處在這個位置的解析器,遇到壞輸入很少直接崩潰。真正傷人的失敗模式是:憑證計數把鄰居的位元組也算進去、簽署者比對落到錯的欄位上,或是一個 OID 不聲不響變成另一個 OID。蓋在這種地基上的簽章管線,輸出的是理直氣壯的胡說八道
從已簽署的 PDF 讀出簽署者憑證
讀取端由五個 TPDFlib 方法覆蓋,參數一律是 InputFile, Password, FieldName:每次呼叫都以唯讀方式開檔、取回答案、再把檔案關上。GetSignatureEmbeddedCertificateCount 與 GetSignatureEmbeddedCertificateDER 按編碼順序列舉 certificates 集合裡的憑證,GetSignatureSignerCertificateDER 回傳產出某個 SignerInfo 的那張憑證,GetSignatureCertificateChainLength 與 GetSignatureCertificateChainDER 則從該簽署者一路往上走,走到簽章本身攜帶的最遠簽發者為止。索引從 0 起算。結果請用 AnsiString 保存——函式庫正是因為這樣才以這個型別回傳:一份 DER blob 若繞道 string 或 TStrings,會先被字元集轉換洗一遍,回來就是爛的
uses
SysUtils, Classes, PDFlibrary;
procedure SaveDer(const FileName: string; const Der: AnsiString);
var
Fs: TFileStream;
begin
Fs := TFileStream.Create(FileName, fmCreate);
try
if Der <> '' then
Fs.WriteBuffer(Der[1], Length(Der));
finally
Fs.Free;
end;
end;
const
Src = 'contract-signed.pdf';
Field = 'Signature1';
var
Pdf: TPDFlib;
ChainLen, I: Integer;
SignerDer, LastDer: AnsiString;
begin
Pdf := TPDFlib.Create;
try
WriteLn('Certificates in the CMS: ',
Pdf.GetSignatureEmbeddedCertificateCount(Src, '', Field));
SignerDer := Pdf.GetSignatureSignerCertificateDER(Src, '', Field, 0);
if SignerDer = '' then
raise Exception.Create('signer certificate missing or not matched');
SaveDer('signer.cer', SignerDer);
ChainLen := Pdf.GetSignatureCertificateChainLength(Src, '', Field, 0);
for I := 0 to ChainLen - 1 do
SaveDer(Format('chain-%d.cer', [I]),
Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, I));
if ChainLen > 0 then
begin
LastDer := Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, ChainLen - 1);
WriteLn('Next issuer, if any: ', Pdf.GetCertificateIssuerURLs(LastDer));
end;
finally
Pdf.Free;
end;
end;
這段輸出有兩個地方要留心。計數為 0 不構成任何診斷:欄位不存在、密碼打錯、blob 根本不是 DER、SignedData 索性省略了選用的 certificates 集合,回傳統統是 0 或空字串,所以記數字時要把欄位名稱一併記上。而鏈在自簽憑證之前就結束,也不是錯誤。鏈建構器只使用簽章裡內嵌的憑證,剩下的簽發者得靠 GetCertificateIssuerURLs 回報的位址去取
/Contents 裡到底哪一段才是 CMS?
只有外層 SEQUENCE 宣告的前綴屬於 CMS,PLTrimCMSPadding 負責把其後的東西全部切掉。簽署端得在 CMS 存在之前就先保留 /Contents 的 hex 字串,因為 ISO 32000-1 §12.8.1 描述的 /ByteRange 必須先固定下來,所以槽位開得寬裕,用不到的尾部全是零。PLTrimCMSPadding 讀出第一個 TLV,要求 tag 必須是 $30,回傳該元素起點到結尾之間的位元組;開頭不是格式完好之 SEQUENCE 的東西,回傳空值。頂層是唯一允許尾部殘餘位元組的地方,這個區分對下一節很關鍵:「元素必須吃滿整個緩衝區」的嚴格規則會拒收每一份真實簽章,而把寬鬆規則套到每一層,又會讓巢狀欄位讀到不屬於自己的位元組
DER 讀取器為什麼需要父元素的結尾偏移量?
巢狀元素只有在結尾落在父元素之內時才算有效,而拿緩衝區結尾去檢查證明不了這一點。PDFlibASN1 裡低階的 DERReadTLV 以整個字串為每個元素定界,這對最外層物件是正確檢查,對其下所有層級則是錯的。想像一個 SignerInfo,它的 issuerAndSerialNumber 宣告 40 位元組,內裡的 issuer Name 卻號稱 60。每個位元組都還在緩衝區裡,所以以緩衝區為界的讀取器會放行那個 Name,接著把序列號從後面的摘要演算法裡讀出來,再把這一對拿去跟內嵌憑證比對。v3.539.10 之前,CMS 走訪器就是這樣讀的。修法是一個小包裝函式,把父元素的結尾位置帶進每一次讀取
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
Result := False;
// 父元素內已無剩餘:拒絕開始讀取
if (Offset < 1) or (Offset >= ParentEnd) then
Exit;
if not DERReadTLV(Data, Offset, Tag, Start, Len) then
Exit;
// Offset 此時停在元素之後一個位置;不得越過父元素
Result := Offset <= ParentEnd;
end;
// each level records its own end and hands it down:
// OuterEnd := end of ContentInfo (RFC 5652 section 3)
// ExplicitEnd := end of content [0] EXPLICIT
// ContentEnd := end of SignedData (RFC 5652 section 5.1)
// SignerEnd / InnerEnd for SignerInfo and issuerAndSerialNumber
PDFlibCMSRead 單元現在把這些結尾貫穿 ContentInfo、[0] EXPLICIT 包裝、SignedData 一路到 signerInfos 的各欄位、以 issuerAndSerialNumber 與 [0] subjectKeyIdentifier 兩種形式出現的 SignerIdentifier(RFC 5652 §5.3),以及比對簽署者時從每張內嵌憑證讀出的 tbsCertificate 欄位。在 certificates 集合與 signerInfos 集合裡,跑過集合結尾的元素會終止迴圈:PLExtractCMSCertificates 回傳已經收下的憑證,絕不把後面 crls 或 signerInfos 的位元組黏到最後一張上。issuer 與 serial 的比對也要求兩半俱備,因為序列號只在同一個簽發者之內唯一
為什麼 2.999.3 會編碼成 1.15.3?
OID 的前兩個弧合併成一個子識別碼,不是一個位元組,而這個子識別碼跟其他弧一樣用 base-128 編碼。X.690 §8.19.4 把它定義為 40 * arc1 + arc2;早期的 DER_OID 用 Byte(...) 寫出這個值,只有在 127 以內才正確——那正是 2.47 的值。2.999 的和是 1079,轉型成 byte 只留下 55,55 解碼出來是 1.15,於是這個識別碼不聲不響指到樹上另一個分支。128 到 255 之間的值以另一種方式壞掉:吐出一個帶續接位元的位元組,把下一個弧吞掉。大多數 PKI 識別碼(1.2.840...、2.5.29...、0.4.0...)永遠碰不到邊界,這就是它活到今天的原因;而 2.48 以上的 joint-iso-itu-t 弧碰得到。DER_OID 同時服務 signed attributes 的編碼器、DERFindExtensionByOID 裡的比對器與 SignedData content-type 檢查,所以編碼一錯,寫入與查詢一起遭殃
uses
SysUtils, PDFlibASN1;
function Hex(const S: AnsiString): string;
var
I: Integer;
begin
Result := '';
for I := 1 to Length(S) do
Result := Result + IntToHex(Byte(S[I]), 2) + ' ';
Result := Trim(Result);
end;
begin
WriteLn(Hex(DER_OID('2.999.3'))); // 06 03 88 37 03
WriteLn(Hex(DER_OID('2.47.1'))); // 06 02 7F 01
WriteLn(Hex(DER_OID('2.48.1'))); // 06 03 81 00 01
WriteLn(Hex(DER_OID('2.5.29.14'))); // 06 03 55 1D 0E
WriteLn(Hex(DER_OID('1.2.840.113549.1.7.2'))); // 06 09 2A 86 48 86 F7 0D 01 07 02
end.
合併後的值特意用 UInt64 保存,是有原因的。DER_OID 把弧剖析進 Int64,所以合法的第二個弧可以大到 Int64.MaxValue,arc1 = 2 時再加上 80 就會溢位帶正負號的 64 位元整數。UInt64 裝得下 Int64.MaxValue + 80 而不發生迴繞,十位元組的暫存緩衝也剛好裝得下 64 位元值需要的十組 7-bit。值得留下的測試向量是邊界兩側的那兩個:2.47 必須維持一個位元組,2.48 必須變成兩個
讀取端的 CMS 走訪器保證什麼?
PDFlibCMSRead 只保證結構,其他一概不保證:它回傳的位元組待在 RFC 5652 說它們該在的位置,但不驗任何簽章、摘要或有效期。走訪器只收 DER,所以 DERReadTLV 拒絕不定長度與多位元組 tag 編號,不合規簽署端產出的 BER 編碼 CMS 會回報零張憑證,而不是給出一個半猜的結果。屬性憑證與 CertificateChoices 的其他選項一律跳過,因為下游沒有任何東西用得上。密碼學驗證交給擁有它的程式碼:起點是Delphi 中的 PAdES 簽章與 ByteRange 驗證描述的位元組覆蓋檢查,接著是分類 PDF 簽署後發生了什麼改動
更廣的教訓適用於任何二進位格式:「在緩衝區之內」是記憶體安全的性質,「在父元素之內」是正確性的性質,解析器兩者都需要。同一套對惡意長度的思考,貫穿在強化 Pascal PDF 解析器以抵禦惡意檔案一文裡。本文討論的憑證抽出、鏈建構與長期驗證 API,都隨 losLab PDF Library for Delphi 出貨,支援 Delphi、C++Builder 與 Lazarus