CMS 容器內的 ECDSA signatureValue,是一個 DER 格式的 SEQUENCE { INTEGER r, INTEGER s }。Windows CNG 函式 BCryptVerifySignature 兩者都不接受:它要的是 IEEE P1363 格式的固定寬度 r || s,不帶標籤(tag)、也不帶長度資訊。HotPDF 是 Delphi 與 C++Builder 適用的原生 VCL PDF 元件,會在匯入金鑰之前,依照嚴謹的 DER 規則在這兩種格式之間轉換
這樣做所避免的失敗情境,是一種具體而令人洩氣的狀況。Acrobat 開啟文件並顯示綠色核可標記,你自己的驗證器走訪同一組位元組,卻回傳無效,或是 CNG 回傳 STATUS_INVALID_SIGNATURE、不附帶任何進一步說明。簽章本身完全沒有問題,出問題的是把大約七十個位元組的 ASN.1 資料,傳給了一個預期收到六十四個位元組原始整數的 API,而這種不匹配除非你知道該往哪裡找,否則根本看不出來
為何 BCryptVerifySignature 會拒絕一個有效的 ECDSA 簽章?
因為這次呼叫的兩端,說的是兩種不同的簽章編碼方式,而且雙方都不會主動告知對方。ISO 32000-1 §12.8 規定簽章字典會在 /Contents 中攜帶一個 CMS 二進位資料塊;RFC 5652 §5.3 則規定每個 SignerInfo 裡的 signatureValue 是一個 OCTET STRING,其內容由簽章演算法自行定義。對 ECDSA 而言,該內容就是 SEC 1 的 DER 結構:一個持有兩個 INTEGER 的 SEQUENCE。它依設計就是可變長度的,因為 r 與 s 都是整數,而 DER 會把整數前導的零位元組去除
IEEE P1363 採取的則是相反的觀點。它把簽章定義為兩個座標值的串接,每個座標值都向左補零,補到剛好等於曲線欄位的位元組寬度。一個 P-256 簽章永遠是 64 個位元組。同一個簽章的 DER 編碼,通常是 70 或 71 個位元組,範圍大約可以落在 8 到 72 個位元組之間。如果把 DER 格式直接交給 BCryptVerifySignature,光是長度檢查就足以讓這次呼叫失敗,這正是為何 HotPDF 要先正規化(normalise)、再驗證,而不是反過來
uses
HPDFECDSA;
// Converts a CMS signatureValue into the fixed-width form CNG expects.
// ARaw comes back as 64 bytes for P-256, 96 for P-384, 132 for P-521.
function ToP1363(const ADerSig: TBytes; ACurve: THPDFECDSACurve;
out ARaw: TBytes): Boolean;
begin
Result := HPDFECDSANormalizeSignature(ADerSig, ACurve, eseDER, ARaw);
end;
簽章解析器絕不能放寬的 DER 規則
這裡列出的每一項拒絕動作,都是 HotPDF 刻意執行的,每一項都封閉了一條寬容解析器會放行的路徑。寫轉換器時很容易產生的誘惑,是找到兩個 INTEGER 節點、複製其內容,然後就結束了。這種做法在格式良好的輸入上確實可行,卻會在惡意輸入上悄悄接受一整類可延展的重新編碼手法。因此 HPDFECDSANormalizeSignature 會拒絕負整數,也就是任何 r 或 s 的首個內容位元組最高位被設定的情況,因為合法的 ECDSA 純量必須是正數。它會拒絕全為零的數值,因為 r = 0 或 s = 0 永遠不會是合法的簽章。它會拒絕多餘的前導零位元組:X.690 §8.3 只允許恰好一個前導零,而且僅限於下一個位元組若不補零就會被讀成負數的情況,所以一個 00 後面接著一個小於 0x80 的位元組,就是一次重新編碼,而非真正的簽章。它會拒絕非最短形式的長度標頭,因為 X.690 §10.1 要求以最少位元組編碼的確定形式,一個本可用短形式表達、卻用了長形式的長度,就是承載相同意義的另一個位元組字串。它會拒絕寬度超過曲線座標大小的整數,因為那樣的值不可能是一個欄位元素。它也會拒絕 s 之後出現的任何尾隨節點,以及外層 SEQUENCE 的總長度與整個資料塊長度不相符的情況
最後這兩項的重要性遠超過它們表面看起來的樣子。SEQUENCE 之後的尾隨位元組,是簽章可延展性攻擊的經典手法:附加一段垃圾資料,寬鬆的驗證器仍然會回答有效,但它所驗證的位元組字串,已經不是當初被簽署的那個位元組字串了。同樣的直覺也驅動了 PKCS#12 解析一文 所描述的 ASN.1 長度強化措施,這裡用的正是同一種直覺。在驗證路徑上,接受一個從未由合規簽署者產生過的結構,是一項缺陷,而不是一種寬容
座標寬度屬於曲線,不屬於簽章
HotPDF 是從具名曲線的 OID 來推導輸出寬度,絕不是從它剛解析出來的 DER 長度來推導。這是整個轉換過程的後半段,也是很容易出現細微錯誤的一半。RFC 5480 §2.1.1 在憑證的 SubjectPublicKeyInfo 參數中識別曲線,HPDFECDSACurveFromOID 則會映射 HotPDF 支援的三個 OID:1.2.840.10045.3.1.7 對應 P-256、1.3.132.0.34 對應 P-384、1.3.132.0.35 對應 P-521。HPDFECDSACoordinateSize 接著會回傳 32、48 或 66 個位元組,而 P1363 緩衝區的大小則是它的兩倍:64、96 或 132 個位元組。每個解碼出來的整數都會靠右對齊填入自己的那一半,所以較短的 r 會在左側補零,而不是位移處理。P-521 是最容易讓人栽跟頭的一個,因為 521 位元等於 65.125 個位元組,無條件進位後是 66,得出一個 132 位元組的簽章,這是任何依照二的次方去猜測的直覺都預料不到的。公鑰則依 RFC 5480 §2.2 以未壓縮的 EC 點形式隨附傳遞,也就是 0x04 加上 X 與 Y,所以 HotPDF 在觸碰 CNG 之前,會先檢查它是否恰好是 1 + 2 * CoordinateSize 個位元組、且以 0x04 開頭
var
Digest, SigDER, PublicPoint: TBytes;
Curve: THPDFECDSACurve;
Res: THPDFECDSAVerifyResult;
begin
// secp256r1, taken from the certificate SubjectPublicKeyInfo parameters
Curve := HPDFECDSACurveFromOID('1.2.840.10045.3.1.7');
// PublicPoint must be $04 || X || Y, so 1 + 2 * 32 = 65 bytes for P-256
Res := HPDFECDSAVerifyDigest(Digest, SigDER, PublicPoint, Curve, eseDER);
case Res of
evrValid:
Memo1.Lines.Add('signature verifies');
evrInvalid:
Memo1.Lines.Add('signature does not match the digest');
evrMalformed:
Memo1.Lines.Add('DER encoding or public point rejected');
evrUnsupported:
Memo1.Lines.Add('curve or algorithm not supported here');
evrProviderUnavailable:
Memo1.Lines.Add('bcrypt.dll or the curve provider is missing');
evrProviderError:
Memo1.Lines.Add('CNG returned an unexpected status');
end;
end;
請留意最後一個參數。HPDFECDSAVerifyDigest 也接受 eseP1363,供已經持有固定寬度簽章的呼叫端使用,例如來自硬體權杖或某個直接回傳原始 r || s 的遠端簽署服務。這條路徑仍然會對兩個半部強制執行長度檢查與非零檢查,所以一個大小正確、內容卻全是零的緩衝區會被拒絕,而不會被直接傳遞給提供者
為何通用的 ECDSA 演算法名稱在較舊版本的 Windows 上會失敗?
因為這個通用名稱,比你部署目標所支援的基礎版本還要新。CNG 提供了一個演算法識別碼 ECDSA,能從匯入的金鑰推斷出曲線,這是撰寫這段程式碼最乾淨的方式,但 BCryptOpenAlgorithmProvider 只保證在較新版本的 Windows 上能解析它。在較舊的機器上,開啟呼叫會失敗,提供者控制代碼會保持為 nil,你的應用程式裡每一次 ECDSA 驗證,都會對一個明明完全正常的簽章回報不支援。HotPDF 的做法是改為開啟各曲線專屬的識別碼,藉此避開這道懸崖。它會一次性解析 ECDSA_P256、ECDSA_P384 與 ECDSA_P521,為每條曲線快取一個提供者控制代碼,並在單元終結時關閉它們。之後每一次驗證,就只需要做便宜的工作:從一個 ECCPUBLICBLOB 匯入一個暫時的公鑰、呼叫 BCryptVerifySignature、再銷毀該金鑰。不必重複呼叫 LoadLibrary、不必重複呼叫 GetProcAddress,也不必每個簽章都開啟並關閉一次提供者。批次驗證幾百份文件時能明顯感受到差異,一個原本會在負載下不斷折騰提供者控制代碼的服務行程,同樣能感受到差異
回傳結果代碼會誠實反映這兩者的差異。evrProviderUnavailable 代表機器無法提供 HotPDF 一個提供者;evrInvalid 代表 CNG 回答了 STATUS_INVALID_SIGNATURE。把這兩者混為一種失敗,正是部署問題被誤報成偽造文件的原因。同樣這種區分環境失敗與密碼學失敗的做法,也貫穿在簽署端的 CNG 與 CAPI 處理邏輯中,詳見 憑證存放區簽署與位元組順序一文
是哪一張憑證簽的?SignerIdentifier 其實是兩種不同的東西
RFC 5652 §5.3 把 SignerIdentifier 定義為一個 CHOICE,只處理其中一種分支的驗證器,會悄悄拿錯誤的金鑰去做驗證。第一種分支是 issuerAndSerialNumber,這是一個持有原始 DER 格式的簽發者名稱與序號 INTEGER 的 SEQUENCE,比對它的方式,是拿 CMS certificates 集合中的每一張憑證逐位元組比對。第二種分支是 [0] subjectKeyIdentifier,一個以隱式標籤標記的 OCTET STRING,比對它需要深入挖掘憑證內部,而不能只比對其標頭欄位
這番挖掘裡有一層會讓人感到意外。金鑰識別碼位於一個 X.509v3 擴充欄位中,所以 HotPDF 會走訪 tbsCertificate 的 [3] 擴充欄位,找出 OID 為 2.5.29.14 的那個擴充項,跳過選用的 critical BOOLEAN,取出 extnValue 這個 OCTET STRING。而這個位元組字串本身並不是識別碼。依照 RFC 5280 §4.2.1.2,它的內容本身又是一段 DER,而 KeyIdentifier 型別是另一個 OCTET STRING,所以你得再解析一次,才能拿到真正的位元組。如果提早一層停手,你就會拿一個 22 位元組的包裝值去跟一個 20 位元組的識別碼比對,結果沒有任何一張憑證會相符,驗證器就會退而使用你接下來寫的任何啟發式方法──這才是真正的危險所在。取用集合中第一張憑證,是個很誘人的捷徑,但只要 CMS 帶有一條憑證鏈──而這是大多數情況──這個做法就是錯的,因為葉節點憑證並沒有規定必須排在第一個。HotPDF 只有在容器裡恰好只有一張憑證時,才會接受一個未經比對的憑證;只要存在多張憑證,就必須做到 SignerIdentifier 精確比對。拿一個中繼 CA 公鑰去驗證摘要,並不會產生一個友善的錯誤,而是會對一份完全沒問題的文件,得出一個信心滿滿的無效結果
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('signed.pdf') > 0 then
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
if Pdf.VerifyLoadedSignatureEx(I, Info) = svValid then
Memo1.Lines.Add(Format('%s: %s %s over %s, signer %s',
[String(Info.FieldName), String(Info.PublicKeyAlgorithm),
String(Info.CurveName), String(Info.HashAlgorithm),
String(Info.SignerName)]))
else
Memo1.Lines.Add(Format('%s: not valid', [String(Info.FieldName)]));
finally
Pdf.Free;
end;
end;
THPDFSignatureInfo.CurveName 會回報 P-256、P-384 或 P-521,讓稽核紀錄能記下實際使用的是哪一條曲線,而不只是「ECDSA」這幾個字。圍繞這次呼叫的文件層級管線,特別是 /ByteRange 各區段是如何被雜湊、以及為何摘要必須針對檔案本身計算、而不是針對解析後的物件樹計算,是姊妹篇 PDF 數位簽章驗證一文 所探討的主題
這樣做無法給你什麼
HPDFECDSAVerifyDigest 回傳的一個綠燈結果,只回答了一個問題:這些位元組確實是由與此公鑰配對的私鑰所簽署的。它完全沒有回答這把金鑰是否屬於某個你應該信任的對象。建立到信任錨點的憑證鏈、透過 CRL 或 OCSP 進行撤銷檢查,以及政策檢查,都是另外獨立的工作,任何在缺少這些檢查的情況下就回報「簽章有效」的產品,回報的內容都比使用者以為的還要少。正因如此,憑證有效期限會另外呈現在 THPDFSignatureInfo 中:一個簽章可以在密碼學上通過驗證,但簽署它的憑證卻可能已經在兩年前就過期了。曲線支援範圍也是刻意收窄的。目前只處理三條 NIST 質數曲線,任何其他曲線上的簽章都會回傳不支援,而不會用猜的。CNG 路徑僅限 Windows,這對一個 VCL 元件來說是正確的取捨,但在你打算圍繞它規劃跨平台服務之前,這一點值得先說清楚。嚴謹程度也不可設定:不存在什麼寬鬆模式,會因為某個舊式簽署工具產生了非最短形式的 DER 長度就接受它。如果你在正式環境中遇到這樣的檔案,誠實的做法是記錄下來、去追查產生者,而不是不斷放寬解析器,直到檔案通過為止
本文所描述的 ECDSA 驗證路徑,隨附於標準版 HotPDF Component(適用於 Delphi 與 C++Builder)之中,與 RSA PKCS#1 v1.5、RSA-PSS 路徑以及完整的簽章資訊記錄一同出貨;產品頁面提供完整的數位簽章參考文件