Bài viết kỹ thuật

Mã hóa RSASSA-PSS-params theo RFC 4055 trong PDFium Delphi

PDFium Component phiên bản 3.114.20 sửa cách mã hóa RSASSA-PSS-params ở cả ba backend ký PAdES, Windows CNG, macOS Keychain và PKCS#11. RFC 4055 §3.1 cho mọi trường của RSASSA-PSS-params một thẻ context-specific tường minh, [0] tới [3], thế mà các backend lại phát saltLength dưới dạng một INTEGER universal trần trong khi vẫn ghi ra một trailerField bằng đúng giá trị mặc định của nó. Các byte chữ ký thì xưa nay vẫn đúng. Cái AlgorithmIdentifier mô tả chúng thì không, và chỉ riêng thế đã đủ để một verifier từ chối chữ ký

Phần bực mình là chỗ con bug trú ngụ. Một chữ ký CMS có hai nửa, phép toán mật mã và phần ASN.1 cho verifier biết phép toán đó đã được thực hiện thế nào. Làm đúng nửa đầu mà sai nửa sau thì kết quả là một tài liệu mà không công cụ nào tuân theo đặc tả phân biệt nổi với một chữ ký giả. Bài này chỉ nói về nửa thứ hai: RSASSA-PSS-params phải được gắn thẻ ra sao, ba backend sai giống hệt nhau như thế nào, và phần DER đã sửa trông thế nào khi nhìn qua TDerWriter

Vì sao một verifier từ chối chữ ký RSASSA-PSS có các byte đúng?

Vì RSASSA-PSS là một lược đồ RSA mà ở đó verifier không thể khôi phục các tham số từ chính chữ ký. Padding PKCS#1 v1.5 được xác định hoàn toàn bởi OID sha256WithRSAEncryption, nên tham số của nó chỉ là một NULL trần và không có gì để sai. PSS thì được tham số hóa bằng một hàm băm, một mask generation function với hàm băm riêng của nó, và một độ dài salt, và RFC 8017 §A.2.3 để cả ba ngỏ. Người ký chọn chúng, AlgorithmIdentifier mang chúng, và verifier phải tái tạo chúng chính xác trước khi EMSA-PSS-VERIFY có thể bắt đầu

Nên khi PDFium Component ký với SHA-256, MGF1 trên SHA-256 và một salt 32 byte, ba dữ kiện đó phải sống sót qua bước mã hóa DER ở đây và bước giải mã DER ở một implementation khác. Một khối tham số mà verifier không parse nổi sẽ kết thúc quá trình xác minh trước khi có bất kỳ phép lũy thừa modulo nào chạy. Một khối mà nó parse khác đi thì còn tệ hơn, vì RFC 4055 §3.1 cho saltLength một giá trị mặc định là 20. Một decoder bỏ qua trường nó không nhận ra sẽ rơi vào giá trị mặc định đó, chạy EMSA-PSS-VERIFY với salt 20 byte trên một chữ ký được tính với 32, rồi báo chữ ký hỏng mà không gợi ý gì rằng vấn đề nằm ở metadata chứ không ở khóa. Cả hai kết cục đều là thứ mà cách mã hóa 3.114.19 sinh ra, tùy verifier nghiêm tới đâu, và không kết cục nào chỉ vào AlgorithmIdentifier

RFC 4055 §3.1 thật ra đòi gì ở RSASSA-PSS-params

RFC 4055 §3.1 định nghĩa RSASSA-PSS-params là một SEQUENCE gồm bốn trường, mỗi trường mang một thẻ context-specific tường minh và một giá trị DEFAULT:

// RSASSA-PSS-params ::= SEQUENCE {
//   hashAlgorithm      [0] HashAlgorithm      DEFAULT sha1,
//   maskGenAlgorithm   [1] MaskGenAlgorithm   DEFAULT mgf1SHA1,
//   saltLength         [2] INTEGER            DEFAULT 20,
//   trailerField       [3] TrailerField       DEFAULT trailerFieldBC
// }

Gắn thẻ tường minh trong DER nghĩa là mỗi trường được bọc trong một TLV context-specific constructed, A0 cho [0], A1 cho [1], A2 cho [2] và A3 cho [3], với phần mã hóa universal của giá trị lồng bên trong. Mọi trường đều được gắn thẻ chính vì mọi trường đều tùy chọn nhờ có giá trị mặc định. Không có thẻ, một decoder không thể biết một SEQUENCE chỉ chứa một AlgorithmIdentifier đang mang hashAlgorithm hay maskGenAlgorithm, vì cả hai đều là kiểu SEQUENCE; có thẻ thì số hiệu thẻ định danh trường đó bất kể những trường kề có mặt hay không. Các giá trị mà PDFium Component phát ra tuân theo profile trong ETSI TS 119 312 §7, SHA-256, MGF1 với SHA-256 và salt bằng độ dài digest, và chúng phản chiếu đúng những gì mỗi lời gọi ký của nền tảng được truyền: một BCRYPT_PSS_PADDING_INFO với cbSalt 32 cho NCryptSignHash, một CK_RSA_PKCS_PSS_PARAMS với sLen 32 cho cơ chế PKCS#11, và thuật toán PSS ký digest SHA-256 trong Security framework

Sơ đồ PDFium Component về RSASSA-PSS-params theo RFC 4055: hashAlgorithm A0, maskGenAlgorithm A1 và saltLength A2 mang thẻ context tường minh cùng giá trị DEFAULT, profile ETSI phát SHA-256, MGF1 với SHA-256 và salt 32, còn trailerField A3 bằng trailerFieldBC nên DER bỏ hẳn nó
Mọi trường đều được gắn thẻ chính vì mọi trường đều tùy chọn nhờ giá trị mặc định của nó, nên số hiệu thẻ định danh trường đó bất kể encoder bỏ sót những trường kề nào

Ba backend cùng mắc một lỗi như thế nào

Cách mã hóa ở 3.114.19 gắn thẻ cho hai trường đầu rồi để hai trường cuối trần trụi, giống hệt nhau ở TWinCmsSigner, TKeychainCmsSigner và TPkcs11CmsSigner. Sự đối xứng đó không phải ngẫu nhiên: cả ba đều cài đặt interface ICmsSigner từ FPdfCms.pas, và phần thân GetSignatureAlgorithmParams của chúng được viết theo đúng một template. Template đó như sau:

// Trước 3.114.20: [0] và [1] được gắn thẻ, [2] và [3] thì không
Result := W.Sequence(Concat4(
  W.ContextSpecific(0, W.AlgId(OID_SHA256), True),
  W.ContextSpecific(1, W.Sequence(ConcatBytes(
    W.OID(OID_MGF1), W.AlgId(OID_SHA256))), True),
  W.IntegerOf(32),      // INTEGER trần trong khi [2] EXPLICIT mới là thứ bắt buộc
  W.IntegerOf(1)));     // trailerField bằng DEFAULT, phải vắng mặt

Một decoder đi qua SEQUENCE đó sẽ thấy A0, đọc hàm băm, thấy A1, đọc mask generation function, rồi gặp 02 01 20. Đó là một INTEGER universal, mà RSASSA-PSS-params không có member INTEGER nào không gắn thẻ ở bất cứ đâu. Một decoder nghiêm thì dừng ngay đó. Một decoder dễ dãi thì bỏ qua phần tử không nhận ra, không bao giờ tìm thấy A2, gán saltLength giá trị mặc định 20, rồi gặp một INTEGER lạc thứ hai, 02 01 01, và lại gặp đúng vấn đề đó lần nữa. Không đường nào đi tới một salt 32 byte. Một template dùng chung thì hiệu quả khi nó đúng, và cũng hiệu quả y như vậy trong việc sai ba lần khi nó sai, đó là lý do bản vá vào cả ba unit trong một commit và lý do ba phần thân method vẫn giống nhau về cấu trúc sau đó. Một backend tương lai nên chép khối đó từ một trong ba chỗ này thay vì tự suy lại, vì chính chỗ suy lại là nơi sai lầm đã ra đời

Sơ đồ PDFium Component về lỗi DER ở 3.114.19: sau A0 và A1, phần params gặp một 02 01 20 trần ở đúng chỗ thẻ tường minh A2 phải nằm, decoder nghiêm dừng lại còn decoder dễ dãi ký với salt mặc định 20 byte, trong khi 3.114.20 bọc salt 32 byte trong A2
RSASSA-PSS-params không có member INTEGER nào không gắn thẻ, nên các byte lạc đó hoặc là lỗi parse hoặc là âm thầm rơi về độ dài salt mặc định, và không đường nào tới được con số 32 của người ký

Vì sao trailerField bị bỏ đi thay vì được gắn thẻ [3]?

Vì X.690 §11.5 nói rằng encoder DER không được mã hóa một thành phần có giá trị bằng DEFAULT của nó, mà trailerField có DEFAULT là trailerFieldBC, tức số nguyên 1. Cách sửa hiển nhiên cho code cũ, thay W.IntegerOf(1) trần bằng W.ContextSpecific(3, W.IntegerOf(1), True), cho ra một khối mà decoder BER dễ dãi chấp nhận còn decoder DER nghiêm ngặt có quyền từ chối. Giá trị không sai. Sự hiện diện của nó mới sai. Cùng quy tắc đó là lý do ba trường kia có mặt: SHA-256 không phải giá trị mặc định sha1, MGF1 với SHA-256 không phải mặc định mgf1SHA1, và 32 không phải mặc định 20. Nếu backend ký bằng SHA-1 với salt 20 byte, RFC 4055 §3.1 sẽ thu các tham số về một SEQUENCE rỗng, 30 00, và chính SEQUENCE rỗng đó chứ không phải NULL mới là thứ verifier trông đợi. PDFium Component không bao giờ phát ra hình dạng đó vì nó không bao giờ ký với những giá trị ấy, nhưng đó là ca bắt được bất kỳ ai tưởng rằng “không có tham số” luôn viết là 05 00

Đây là khác biệt giữa DER và BER, và nó đặc biệt quan trọng với chữ ký. BER cho phép encoder đưa vào một thành phần mang giá trị mặc định; DER cấm điều đó, vì DER tồn tại để một giá trị chỉ có đúng một cách mã hóa, mà một chữ ký trên cấu trúc có hai cách mã hóa hợp lệ là chữ ký có thể bị tranh cãi. Mọi thứ bên trong signedAttrs của CMS là DER vì lý do đó, và khối tham số đi trong signedAttrs qua attribute cmsAlgorithmProtection cũng như trong signatureAlgorithm ở ngoài, nên nó không được miễn trừ

Sơ đồ PDFium Component về quy tắc X.690 11.5 trong bản vá PSS: trailerField bằng DEFAULT trailerFieldBC của nó phải vắng mặt vì wrapper A3 được gắn thẻ là cách mã hóa mà DER nghiêm ngặt từ chối, còn saltLength 32 khác mặc định 20 nên phải có mặt dưới dạng A2
DER tồn tại để một giá trị chỉ có đúng một cách mã hóa, mà một thành phần bằng giá trị mặc định của nó thì đã có cách mã hóa ngắn nhất rồi, cụ thể là không xuất hiện

Cách mã hóa TDerWriter đã sửa

PDFium Component giờ dựng các tham số bằng ba lời gọi TDerWriter.ContextSpecific từ FPdfAsn1.pas, mỗi trường không mặc định một lời gọi, mỗi lời gọi đặt tham số Constructed thành True để tạo wrapper thẻ tường minh, và không có dòng nào cho trường trailer. Đây là phần thân của TWinCmsSigner.GetSignatureAlgorithmParams với các OID viết hẳn ra; các unit Keychain và PKCS#11 ghi cùng những giá trị đó dưới tên OID_SHA256, OID_MGF1 và OID_RSASSA_PSS:

function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
  W: TDerWriter;
begin
  if FPaddingScheme = psRsaPss then
  begin
    W := TDerWriter.Create;
    try
      // RFC 4055 3.1 gắn thẻ cả bốn trường. saltLength là [2]; một
      // INTEGER trần ở đây sẽ bị đọc thành khởi đầu của một trường khác.
      // trailerField là [3] với DEFAULT 1, mà X.690 11.5 cấm mã hóa
      // một giá trị bằng mặc định, nên nó được bỏ hoàn toàn
      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
  else
    Result := nil;   // PKCS#1 v1.5 và ECDSA: AlgIdWithParams ghi NULL
end;

Hai chi tiết của phần máy móc xung quanh cũng quan trọng. TDerWriter.AlgId tạo ra một AlgorithmIdentifier với tham số NULL, đúng thứ mà RFC 4055 §2.1 bảo encoder sinh ra cho hashAlgorithm lồng bên trong và cho hàm băm nằm trong MGF1. Và bộ dựng CMS trong FPdfCms.pas ghép OID chữ ký với những byte này qua TDerWriter.AlgIdWithParams, thứ thay bằng NULL khi params là nil; đó là lý do psRsaPkcs1v15 và psEcdsa chỉ đơn giản trả về nil và chưa bao giờ bị ảnh hưởng, và cũng là lý do 1.2.840.113549.1.1.10, id-RSASSA-PSS, là OID chữ ký duy nhất trong ba cái mang một khối tham số thật. Các byte thu được cho profile SHA-256 là cố định và đủ ngắn để kiểm bằng mắt: một SEQUENCE ngoài 30 34 bọc A0 0F quanh AlgorithmIdentifier SHA-256 dài 15 byte, A1 1C quanh AlgorithmIdentifier MGF1 dài 28 byte với tham số của chính nó là chính AlgorithmIdentifier SHA-256 đó, và A2 03 02 01 20 cho salt. Nếu một dump signatureAlgorithm của bạn cho thấy 02 01 20 ở mức cao nhất của SEQUENCE params thay vì nằm trong một A2, thì bạn đang nhìn vào cách mã hóa của 3.114.19

Vì sao bộ test không bắt được một AlgorithmIdentifier sai định dạng?

Vì các bài test PAdES chạy bộ dựng CMS qua một signer giả báo sha256WithRSAEncryption và trả về nil từ GetSignatureAlgorithmParams, nên khối tham số PSS chưa bao giờ được dựng trong bất kỳ bài test nào. Đó là một thiết kế hợp lý cho các bài test buộc phải chạy mà không có certificate store, Keychain hay token, và nó có một điểm mù với hình dạng rất rõ: thứ gì chỉ backend thật sinh ra thì cũng chỉ backend thật kiểm chứng. Tầng thứ hai thú vị hơn. PDFium Component còn đặt AlgorithmIdentifier của chữ ký, kể cả tham số, vào bên trong signed attribute cmsAlgorithmProtection của RFC 6211, và verifier so bản sao đó với signatureAlgorithm ở ngoài. Cả hai bản sao đến từ cùng một lời gọi, nên chúng khớp hoàn hảo và mọi phép kiểm tra nhất quán nội bộ đều qua. Cách mã hóa tự nhất quán và sai, đúng loại bug mà so một cấu trúc với chính nó bao nhiêu cũng không phơi ra được, và bài học tương tự với một cấu trúc khác được kể trong signedAttrs của CMS và việc sắp SET OF theo DER, nơi một SET được băm theo thứ tự này và phát ra theo thứ tự khác trông vẫn ổn cho tới khi một verifier lạ tính lại hash

Thứ bắt được loại bug này là một decoder không do chính tác giả của encoder viết, chạy trên đầu ra thật của backend thật. Đường xác minh trên Windows trong PDFium Component đi qua CryptoAPI chứ không qua trình đọc của chính thư viện, và một chữ ký PSS bị từ chối ở đó chính là thứ dẫn ngược về các tham số. Bất kỳ ASN.1 nào một implementation phát ra cho implementation khác đọc đều đáng được ít nhất một vòng round-trip qua một decoder mà nó không kiểm soát, và cấu trúc càng nhiều giá trị mặc định cùng thẻ bao nhiêu thì vòng round-trip đó càng đáng giá bấy nhiêu

Chỗ của bản vá này trong toàn bộ câu chuyện PSS

Bản vá này độc lập với hai chỗ khác mà PSS có thể sai trong một chữ ký PAdES, và tách chúng ra sẽ rút ngắn việc gỡ lỗi. Backend macOS có thể nhận ra một khóa cụ thể hay một hệ thống cũ từ chối PSS rồi hạ cấp về PKCS#1 v1.5, và AlgorithmIdentifier phải đi theo việc hạ cấp đó; đó là câu hỏi về khả năng, được trình bày trong ký PAdES bằng identity trong macOS Keychain. Backend PKCS#11 có thể đưa cho token một CK_RSA_PKCS_PSS_PARAMS mà token đọc bố cục khác đi vì lệch bề rộng số nguyên; đó là câu hỏi về ABI, được trình bày trong CK_ULONG và cái bẫy packing của PKCS#11. Còn bài này nói về kiểu hỏng thứ ba, khi khóa sẵn sàng, token tính ra đúng các byte, mà phần DER mô tả kết quả lại không tuân RFC 4055 §3.1

Một signer khai báo PSS nhận lấy một nghĩa vụ mà signer v1.5 chưa bao giờ có: mô tả các tham số của chính nó dưới một dạng mà implementation khác giải mã ra đúng ba giá trị như vậy. RFC 4055 §3.1 cố định các thẻ, X.690 §11.5 cố định trường nào được xuất hiện, và ETSI TS 119 312 §7 cố định những giá trị đáng chọn. Cả ba backend của PDFium Delphi component đều phát hành dưới dạng mã nguồn, nên phần thân GetSignatureAlgorithmParams ở trên là thứ bạn có thể đọc, dump và so với verifier của chính mình thay vì tin tưởng suông