一段以 A0 0F … A1 1C … 02 01 20 02 01 01 收尾的 RSASSA-PSS signatureAlgorithm 十六進位轉儲看起來很完整:SHA-256、MGF1、32 位元組鹽值、trailer 為 1。嚴格的解碼器會直接拒收這個參數區塊,寬鬆的解碼器則把鹽長度讀成 20。無論哪種,一段 RSA 運算正確無誤的簽章就是被判定無效。RFC 4055 §3.1 解釋了原因:RSASSA-PSS-params 的每個欄位都帶明確的脈絡標記,所以鹽值要放在 A2 裡面,而在 DER 下等於預設值的 trailerField 根本不得出現
這讓 CMS 簽章有了可以各自獨立失敗的兩半。一半是密碼學操作;另一半是告訴驗證器操作怎麼做的 ASN.1。第一半做對、第二半做錯,遵循規範的驗證器就無法分辨你的文件與偽造品。本文談的是 ASN.1 這一半
位元組正確的 RSASSA-PSS 簽章,驗證器為什麼還是拒收
因為 RSASSA-PSS 是唯一一個驗證器無法從演算法 OID 推出參數的 RSA 方案。PKCS#1 v1.5 被 sha256WithRSAEncryption 完全釘死,參數就是一個裸 NULL。PSS 由訊息雜湊、帶自己雜湊的遮罩產生函式、以及鹽長度參數化,而 RFC 8017 §A.2.3 把這三者都交給簽署者決定。AlgorithmIdentifier 承載簽署者的選擇,驗證器必須先精確重現它們,EMSA-PSS-VERIFY 才能開跑
驗證器解析不了的區塊,在任何模冪運算發生之前就終止驗證。解析得「不一樣」的區塊更糟。RFC 4055 §3.1 給 saltLength 預設值 20,所以從找不到鹽欄位的解碼器,會拿 20 位元組的鹽去跑 EMSA-PSS-VERIFY,對抗一份用 32 位元組算出來的簽章。結果回報為簽章錯誤,沒有任何東西指向中繼資料。驗證器有多嚴格,決定你看到哪一種失敗,而兩種錯誤訊息都不會提到 AlgorithmIdentifier
RSASSA-PSS-params 的四個欄位與它們的標記
RFC 4055 §3.1 把 RSASSA-PSS-params 定義成四個欄位的 SEQUENCE,每個都帶明確的 context-specific 標記與 DEFAULT 值。在 DER 裡,明確標記把每個欄位包進一個結構化的脈絡 TLV——A0 到 A3——欄位自己的 universal 編碼則巢在裡面:
| 欄位 | 標記(DER 位元組) | DEFAULT | ETSI TS 119 312 §7 值 | 是否編碼 |
|---|---|---|---|---|
hashAlgorithm | [0](A0) | sha1 | SHA-256 | 是 |
maskGenAlgorithm | [1](A1) | mgf1SHA1 | MGF1 with SHA-256 | 是 |
saltLength | [2](A2) | 20 | 32(摘要長度) | 是 |
trailerField | [3](A3) | trailerFieldBC(1) | 1 | 否,等於預設值 |
標記之所以存在,是因為每個欄位都靠預設值成為可選。hashAlgorithm 與 maskGenAlgorithm 都是 AlgorithmIdentifier SEQUENCE;沒有標記的話,看著一個只含單一 SEQUENCE 的參數區塊,解碼器無從分辨它是兩者中的哪一個。有了標記,標記號碼就指名了欄位,無論編碼器省略了哪些鄰居
把鹽選成等於摘要長度,然後固定在那裡。有些 API 提供「最大鹽值」或「鹽長度自動」,得到的值取決於金鑰大小;這在 RFC 8017 下合法,但 AlgorithmIdentifier 接著就得寫出一個隨金鑰而變的數字,而 ETSI TS 119 312 §7 反正要的就是 SHA-256 配 32 位元組鹽。PDFium Component 內建的簽署後端全部固定 32,並把同一個數字傳給平台呼叫:Windows 上交給 NCryptSignHash 的 BCRYPT_PSS_PADDING_INFO 裡的 cbSalt、PKCS#11 token 的 CK_RSA_PKCS_PSS_PARAMS 裡的 sLen、OpenSSL 下的 EVP_PKEY_CTX_set_rsa_pss_saltlen,以及 macOS 上 kSecKeyAlgorithmRSASignatureDigestPSSSHA256 隱含的摘要長度鹽
為自己的 ICmsSigner 撰寫 GetSignatureAlgorithmParams
在 PDFium Component 裡,每個簽署後端都實作 FPdfCms.pas 的 ICmsSigner 介面,參數區塊來自單一方法 GetSignatureAlgorithmParams。CMS 建構器透過 TDerWriter.AlgIdWithParams 把它的結果與 GetSignatureAlgorithmOid 給出的 OID 配對。當你透過 ConfigurePadesSignerProvider 插入自己的簽署器——網路 HSM、簽署服務、內建後端沒涵蓋的智慧卡驅動——就會遇到這個方法。多數人第一次寫出的編碼,會給兩個 AlgorithmIdentifier 加標記(因為它們明顯需要包裝),卻讓兩個整數裸奔:
// 常見的第一次嘗試:[0] 與 [1] 帶標記,[2] 與 [3] 沒有
Result := W.Sequence(Concat4(
W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
W.ContextSpecific(1, W.Sequence(ConcatBytes(
W.OID('1.2.840.113549.1.1.8'),
W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
W.IntegerOf(32), // 該放 [2] EXPLICIT 的位置卻是裸 INTEGER
W.IntegerOf(1))); // trailerField 等於 DEFAULT:必須省略
照解碼器的走法走一遍。它看到 A0 就讀雜湊,看到 A1 就讀遮罩產生函式,接著撞上 02 01 20——一個 universal INTEGER。RSASSA-PSS-params 從頭到尾沒有未標記的 INTEGER 成員,所以嚴格的解碼器停了下來。寬鬆的解碼器跳過未知元素,永遠找不到 A2,把預設鹽值 20 指派上去,然後又被第二個走失的 INTEGER 02 01 01 絆倒。兩條路都到不了 32
合規的版本用 FPdfAsn1.pas 的 TDerWriter.ContextSpecific 呼叫三次,每個非預設欄位一次,每次都把 Constructed 設為 True,讓結果成為明確包裝。trailer 欄位沒有那一行。這正是 Windows CNG、macOS Keychain、PKCS#11 與 OpenSSL 後端輸出的同一個區塊,這裡以自訂簽署器會攜帶的形式寫出:
uses
System.SysUtils, FPdfSha256, FPdfAsn1, FPdfCms;
type
TMyHsmSigner = class(TInterfacedObject, ICmsSigner)
private
FUsePss: Boolean;
public
function GetDigestAlgorithmOid: AnsiString;
function GetSignatureAlgorithmOid: AnsiString;
function GetSignatureAlgorithmParams: TBytes;
function GetSignerId: TCmsSignerId;
function SignSignedAttrsDigest(const Digest: TSHA256Digest): TBytes;
end;
// 你自己的類別;其餘四個方法負責與你的 HSM 對話,這裡不列出
function TMyHsmSigner.GetSignatureAlgorithmParams: TBytes;
var
W: TDerWriter;
begin
if not FUsePss then
Exit(nil); // v1.5 與 ECDSA:AlgIdWithParams 對 nil 寫入 NULL
W := TDerWriter.Create;
try
// RFC 4055 3.1:[0] 雜湊、[1] 帶自己雜湊的 MGF1、[2] 鹽值。
// [3] trailerField = 1 等於其 DEFAULT,X.690 11.5 因此省略它
Result := W.Sequence(Concat3(
W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
W.ContextSpecific(1, W.Sequence(ConcatBytes(
W.OID('1.2.840.113549.1.1.8'), // id-mgf1
W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
W.ContextSpecific(2, W.IntegerOf(32), True)));
finally
W.Free;
end;
end;
TDerWriter.AlgId 寫出帶 NULL 參數的 AlgorithmIdentifier,這正是 RFC 4055 §2.1 要求編碼器為巢狀 SHA-256 識別碼生成的形式。結果的位元組短到可以用眼睛驗:外層 30 34,然後 A0 0F 包著 15 位元組的 SHA-256 AlgorithmIdentifier、A1 1C 包著 28 位元組的 MGF1 AlgorithmIdentifier、A2 03 02 01 20 是鹽值。你在 GetSignatureAlgorithmParams 裡寫的鹽必須與 HSM 實際使用的鹽相符;如果服務只暴露「PSS with SHA-256」而沒告訴你鹽長度,出貨之前先問清楚,因為 AlgorithmIdentifier 就是對它的宣稱
trailerField 為什麼是省略,而不是標成 [3]
X.690 §11.5 規定 DER 編碼器不得編碼值等於其 DEFAULT 的元件。trailerField 的 DEFAULT 是 trailerFieldBC(整數 1),而 RFC 8017 定義的 PSS 只用那個 trailer。所以對第一次嘗試最直覺的修法——把 W.IntegerOf(1) 換成 W.ContextSpecific(3, W.IntegerOf(1), True)——會產出 A3 03 02 01 01:寬容的 BER 解碼器接受,嚴格的 DER 解碼器有正當理由拒收。值是對的;它的在場不對
同一條規則也解釋了為什麼其他三個欄位確實出現:SHA-256 不是 sha1,MGF1 with SHA-256 不是 mgf1SHA1,32 不是 20。用 SHA-1 配 20 位元組鹽的簽署者會把整個區塊塌縮成空 SEQUENCE 30 00。這個形狀會絆倒所有假設「無參數」一律寫成 05 00 的人;對 CMS 裡的 id-RSASSA-PSS 而言參數欄位是必須的,空 SEQUENCE 意味「全部預設」,而 NULL 不是合法的 RSASSA-PSS-params 值
BER 允許編碼器把預設值元件寫出來;DER 禁止,因為 DER 的存在就是為了讓一個值只有一種編碼,而對擁有兩種合法編碼的結構做簽章,是可以被爭論的。參數區塊在這裡躲不開 DER:它位於 SignerInfo 外層的 signatureAlgorithm,並且透過 RFC 6211 的 cmsAlgorithmProtection 屬性複製進簽署屬性,那裡每個位元組都被簽章覆蓋
檢查一份不是你建出來的 CMS
cmsAlgorithmProtection 裡那份複本抓不到錯標的區塊。屬性與外層 signatureAlgorithm 來自同一次呼叫,所以永遠相符,拿結構跟自己比對當然通過。真正抓到問題的,是逐欄位讀參數的解碼器。PDFium Component 在 FPdfPades.pas 裡的 InspectPadesSignatureAlgorithm 就是這樣:它只接受 A0 到 A3,每個至多一次且按遞增順序,為缺漏者填入 RFC 4055 預設值,並在 TPadesRsaPssParameters 回報有效值。區塊裡的裸 INTEGER 會讓 SignaturePss.Valid 為 False,EvaluatePadesSignatureAlgorithm 接著把 PolicyStatus 設為 pcsInvalid。它也拒收與 SignerInfo 摘要演算法不同的 PSS 雜湊——那正是寬鬆回退到 sha1 會產生的結果
遠端簽署最能體會這件事的重要:外部服務回傳做好的 CMS,CompletePadesRemoteSignature 把它嵌進備妥的 PDF。嵌入之前先檢查這個 blob。在那個時間點拒收只花一次 API 呼叫;從客戶的驗證器得知,代價是一輪重簽,還得勞動服務的營運者:
uses
System.SysUtils, FPdfPades;
// 建議你在 CompletePadesRemoteSignature 之前自行加入的檢查
function CheckRemotePssCms(const CmsDer: TBytes; out Why: string): Boolean;
const
OID_RSASSA_PSS = '1.2.840.113549.1.1.10';
var
Info: TPadesSignatureAlgorithmInfo;
Values: string;
begin
Result := False;
Why := '';
// nil:讓檢查器自己定位 CMS 內的簽署者憑證
if not InspectPadesSignatureAlgorithm(CmsDer, nil, Info) then
begin
Why := 'CMS SignerInfo could not be parsed';
Exit;
end;
if Info.SignatureAlgorithmOid = OID_RSASSA_PSS then
begin
if not Info.SignaturePss.Valid then
begin
Why := 'RSASSA-PSS-params does not decode as RFC 4055 3.1';
Exit;
end;
// 有效值,含預設:缺漏的 A2 在這裡顯示為 20
Why := Format('hash %s, MGF hash %s, salt %d', [
string(Info.SignaturePss.HashAlgorithmOid),
string(Info.SignaturePss.MaskGenHashAlgorithmOid),
Info.SignaturePss.SaltLength]);
end;
if Info.PolicyStatus = pcsInvalid then
Exit; // Why 仍留著解碼值,供日誌使用
Values := Why;
Result := VerifyCmsAlgorithmProtection(CmsDer, Why);
if Result then
Why := Values; // 保護檢查成功時會清空 Why
end;
就算成功也要把 Why 字串寫進日誌。SHA-256 雜湊旁邊出現 20 的鹽長度,就是 A2 遺失的參數區塊的指紋,無論服務自己的日誌怎麼說。不想寫程式、想快速看一眼的話,openssl asn1parse -inform DER -in signature.p7s 會印出這棵樹;在 id-RSASSA-PSS OID 底下你該看到 cont [ 2 ] 包著十六進位 20 的 INTEGER,而且完全沒有 cont [ 3 ]。那個 SEQUENCE 裡的頂層 INTEGER,就是第一段程式碼的錯標編碼
這件事在 PSS 故事裡的位置
參數編碼是 PSS 在 PAdES 簽章裡三種各自獨立的翻車方式之一,把它們分清楚能縮短除錯。macOS 上特定的金鑰或較舊的系統可能不提供 PSS,後端於是改用 PKCS#1 v1.5 簽署並宣告 sha256WithRSAEncryption 以求一致;這個能力問題在以 macOS Keychain 身分簽署 PAdES 一文有涵蓋。PKCS#11 token 讀取 CK_RSA_PKCS_PSS_PARAMS 時的整數寬度可能與呼叫端寫入的不同,這個 ABI 問題見CK_ULONG 與 PKCS#11 結構對齊陷阱。第三種就是上文:金鑰願意、token 算出正確位元組,而描述它們的 DER 不遵循 RFC 4055 §3.1
簽署屬性 SET 有它自己的自洽盲區版本:按一種順序雜湊、按另一種順序輸出的 SET,在外部驗證器重算雜湊之前一切正常;見CMS signedAttrs 與 DER SET OF 排序。至於上文檢查所接入的「準備、遠端簽署、完成」循環,以 HSM 遠端簽署 PAdES 涵蓋了兩階段協定與完成步驟拒收的東西
PDFium Delphi component 的簽署後端以原始碼形式出貨,所以 Windows、macOS、PKCS#11 與 OpenSSL 各單元裡的 GetSignatureAlgorithmParams 本體就在那裡,可以讀、可以轉儲,與你自己簽署器的輸出逐位元組對照