Artikel Teknis

Mengodekan RSASSA-PSS-params Sesuai RFC 4055 di PDFium

PDFium Component versi 3.114.20 memperbaiki pengodean RSASSA-PSS-params di ketiga backend penandatanganan PAdES: Windows CNG, macOS Keychain, dan PKCS#11. RFC 4055 §3.1 memberi setiap field RSASSA-PSS-params tag context-specific eksplisit, [0] sampai [3], dan backend-nya mengeluarkan saltLength sebagai INTEGER universal telanjang sambil ikut menulis trailerField yang nilainya sama dengan default-nya. Byte signature-nya benar dari awal sampai akhir. AlgorithmIdentifier yang mendeskripsikannya tidak, dan itu saja sudah cukup membuat verifier menolak tanda tangannya

Bagian yang menjengkelkan adalah di mana bug-nya bersembunyi. Tanda tangan CMS punya dua paruh: operasi kriptografinya dan ASN.1 yang memberi tahu verifier bagaimana operasi itu dilakukan. Benarkan yang pertama dan salahkan yang kedua, hasilnya dokumen yang tidak bisa dibedakan oleh tool mana pun yang mengikuti spesifikasi dari sebuah pemalsuan. Artikel ini hanya membahas paruh keduanya: bagaimana RSASSA-PSS-params harus diberi tag, bagaimana tiga backend melakukan kesalahan yang sama, dan seperti apa DER yang dibetulkan dalam istilah TDerWriter

Kenapa verifier menolak tanda tangan RSASSA-PSS yang byte-nya benar?

Karena RSASSA-PSS adalah satu-satunya skema RSA yang parameter-nya tidak bisa dipulihkan verifier dari tanda tangannya sendiri. Padding PKCS#1 v1.5 sepenuhnya ditentukan oleh OID sha256WithRSAEncryption, jadi parameternya berupa NULL telanjang dan tidak ada yang bisa salah. PSS diparameterkan oleh sebuah fungsi hash, sebuah mask generation function dengan hash-nya sendiri, dan panjang salt, dan RFC 8017 §A.2.3 membiarkan ketiganya terbuka. Penandatangannya yang memilih, AlgorithmIdentifier yang membawanya, dan verifier harus mereproduksi ketiganya persis sebelum EMSA-PSS-VERIFY bahkan bisa dimulai

Jadi ketika PDFium Component menandatangani dengan SHA-256, MGF1 atas SHA-256, dan salt 32 byte, ketiga fakta itu harus bertahan melewati pengodean DER di sini dan pendekodean DER di implementasi lain. Blok parameter yang tidak bisa di-parse verifier mengakhiri verifikasi sebelum eksponensiasi modular mana pun terjadi. Blok yang di-parse secara berbeda lebih buruk lagi, karena RFC 4055 §3.1 memberi saltLength default 20. Decoder yang melewati field yang tidak ia kenali akan mendarat di default itu, menjalankan EMSA-PSS-VERIFY dengan salt 20 byte terhadap tanda tangan yang dihitung dengan 32, lalu melaporkan tanda tangan yang buruk tanpa petunjuk bahwa masalahnya ada di metadata dan bukan di key-nya. Kedua hasil itu yang dihasilkan pengodean 3.114.19, tergantung seberapa ketat verifier-nya, dan tidak satu pun menunjuk ke AlgorithmIdentifier-nya

Apa yang sebenarnya diwajibkan RFC 4055 §3.1 atas RSASSA-PSS-params

RFC 4055 §3.1 mendefinisikan RSASSA-PSS-params sebagai SEQUENCE berisi empat field, masing-masing membawa tag context-specific eksplisit dan sebuah nilai 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
// }

Explicit tagging di DER berarti setiap field dibungkus dalam TLV context-specific yang constructed, A0 untuk [0], A1 untuk [1], A2 untuk [2], dan A3 untuk [3], dengan pengodean universal nilainya bersarang di dalamnya. Setiap field diberi tag justru karena setiap field opsional lewat default-nya. Tanpa tag, decoder tidak bisa tahu apakah SEQUENCE yang membawa satu AlgorithmIdentifier sedang membawa hashAlgorithm atau maskGenAlgorithm, karena keduanya bertipe SEQUENCE; dengan tag, nomor tag-nya mengidentifikasi field-nya terlepas dari tetangga mana yang ada. Nilai yang dikeluarkan PDFium Component mengikuti profil di ETSI TS 119 312 §7, SHA-256, MGF1 dengan SHA-256, dan salt sepanjang digest-nya, dan nilainya mencerminkan persis apa yang diberitahukan ke setiap pemanggilan penandatanganan di platformnya: BCRYPT_PSS_PADDING_INFO dengan cbSalt 32 untuk NCryptSignHash, CK_RSA_PKCS_PSS_PARAMS dengan sLen 32 untuk mekanisme PKCS#11-nya, dan algoritma PSS penandatanganan digest SHA-256 di Security framework

Diagram PDFium Component tentang RSASSA-PSS-params menurut RFC 4055: hashAlgorithm A0, maskGenAlgorithm A1 dan saltLength A2 membawa tag context eksplisit dengan nilai DEFAULT, profil ETSI-nya mengeluarkan SHA-256, MGF1 dengan SHA-256 dan salt 32, dan trailerField A3 sama dengan trailerFieldBC sehingga DER menghilangkannya sepenuhnya
Setiap field diberi tag justru karena setiap field opsional lewat default-nya, sehingga nomor tag-nya mengidentifikasi field-nya tak peduli tetangga mana yang dihilangkan encoder-nya

Bagaimana tiga backend melakukan kesalahan yang sama

Pengodean 3.114.19 memberi tag pada dua field pertama dan membiarkan dua field terakhir telanjang, secara identik di TWinCmsSigner, TKeychainCmsSigner, dan TPkcs11CmsSigner. Kesimetrian itu bukan kebetulan: ketiganya mengimplementasikan antarmuka ICmsSigner dari FPdfCms.pas, dan isi GetSignatureAlgorithmParams masing-masing ditulis dari satu template. Template-nya berbunyi seperti ini:

// Sebelum 3.114.20: [0] dan [1] diberi tag, [2] dan [3] tidak
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 telanjang di tempat yang seharusnya [2] EXPLICIT
  W.IntegerOf(1)));     // trailerField, sama dengan DEFAULT, harus tidak ada

Decoder yang menelusuri SEQUENCE itu melihat A0, membaca algoritma hash-nya, melihat A1, membaca mask generation function-nya, lalu bertemu 02 01 20. Itu INTEGER universal, dan RSASSA-PSS-params tidak punya anggota INTEGER tanpa tag di mana pun. Decoder yang ketat berhenti di situ. Decoder yang longgar melewati elemen yang tidak dikenalnya, tidak pernah menemukan A2, memberi saltLength nilai default 20, lalu menemukan INTEGER nyasar kedua, 02 01 01, dan menghadapi masalah yang sama lagi. Tidak ada satu pun jalur yang sampai ke salt 32 byte. Template bersama itu efisien kalau benar dan sama efisiennya untuk salah tiga kali kalau tidak, itulah sebabnya perbaikannya mendarat di ketiga unit itu dalam satu commit dan kenapa isi ketiga method-nya tetap identik secara struktural setelahnya. Backend di masa depan sebaiknya menyalin bloknya dari salah satu ini alih-alih menurunkannya lagi, karena penurunannya justru tempat kesalahannya terjadi

Diagram PDFium Component tentang bug DER di 3.114.19: setelah A0 dan A1 parameter-nya bertemu 02 01 20 telanjang di tempat tag eksplisit A2 seharusnya berada, decoder ketat berhenti dan yang longgar memverifikasi dengan salt default 20 byte, sementara 3.114.20 membungkus salt 32 byte-nya di dalam A2
RSASSA-PSS-params tidak punya anggota INTEGER tanpa tag, jadi byte nyasar itu entah membuat parse gagal atau diam-diam jatuh ke panjang salt default, dan tidak ada jalur yang sampai ke 32 milik penandatangannya

Kenapa trailerField dihilangkan alih-alih diberi tag [3]?

Karena X.690 §11.5 menyatakan encoder DER tidak boleh mengodekan komponen yang nilainya sama dengan DEFAULT-nya, dan trailerField punya DEFAULT trailerFieldBC, yaitu integer 1. Perbaikan yang kelihatan jelas atas kode lama, mengganti W.IntegerOf(1) telanjang dengan W.ContextSpecific(3, W.IntegerOf(1), True), menghasilkan blok yang diterima decoder BER yang permisif dan berhak ditolak decoder DER yang ketat. Nilainya tidak salah. Kehadirannya yang salah. Aturan yang sama juga yang membuat tiga field lainnya ada: SHA-256 bukan default sha1, MGF1 dengan SHA-256 bukan default mgf1SHA1, dan 32 bukan default 20. Seandainya backend-nya menandatangani dengan SHA-1 dan salt 20 byte, RFC 4055 §3.1 akan meruntuhkan parameter-nya menjadi SEQUENCE kosong, 30 00, dan SEQUENCE kosong itulah, bukan NULL, yang diharapkan verifier. PDFium Component tidak pernah mengeluarkan bentuk itu karena tidak pernah menandatangani dengan nilai-nilai tersebut, tapi itulah kasus yang menjebak siapa pun yang mengasumsikan "tanpa parameter" selalu dieja 05 00

Inilah pembedaan antara DER dan BER yang khususnya penting untuk tanda tangan. BER membiarkan encoder menyertakan komponen bernilai default; DER melarangnya, karena DER ada agar satu nilai punya tepat satu pengodean, dan tanda tangan atas struktur dengan dua pengodean legal adalah tanda tangan yang bisa diperdebatkan. Semua yang ada di dalam signedAttrs CMS adalah DER karena alasan itu, dan blok parameter-nya berjalan di dalam signedAttrs lewat atribut cmsAlgorithmProtection maupun di signatureAlgorithm luarnya, jadi ia tidak mendapat pengecualian

Diagram PDFium Component tentang aturan X.690 11.5 pada perbaikan PSS: trailerField yang sama dengan DEFAULT-nya trailerFieldBC harus tetap tidak ada karena wrapper A3 yang diberi tag justru pengodean yang ditolak DER ketat, sementara saltLength 32 berbeda dari default 20 dan harus hadir sebagai A2
DER ada agar satu nilai punya tepat satu pengodean, dan komponen yang sama dengan default-nya sudah punya pengodean terpendek yang mungkin, yaitu tidak muncul sama sekali

Pengodean TDerWriter yang dibetulkan

PDFium Component kini membangun parameter-nya dengan tiga pemanggilan TDerWriter.ContextSpecific dari FPdfAsn1.pas, satu per field non-default, masing-masing dengan argumen Constructed diset True untuk menghasilkan wrapper tag eksplisitnya, dan tanpa satu baris pun untuk field trailer-nya. Ini isi TWinCmsSigner.GetSignatureAlgorithmParams dengan OID-nya ditulis lengkap; unit Keychain dan PKCS#11-nya mengeja nilai yang sama sebagai OID_SHA256, OID_MGF1, dan OID_RSASSA_PSS:

function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
  W: TDerWriter;
begin
  if FPaddingScheme = psRsaPss then
  begin
    W := TDerWriter.Create;
    try
      // RFC 4055 3.1 memberi tag keempat field. saltLength adalah [2];
      // INTEGER telanjang di sini dibaca sebagai awal field lain. trailerField
      // adalah [3] dengan DEFAULT 1, dan X.690 11.5 melarang mengodekan nilai
      // yang sama dengan default-nya, jadi ia dihilangkan sepenuhnya
      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 dan ECDSA: AlgIdWithParams menulis NULL
end;

Dua detail dari mesin di sekelilingnya penting. TDerWriter.AlgId menghasilkan AlgorithmIdentifier dengan parameter NULL, yang memang diperintahkan RFC 4055 §2.1 untuk dihasilkan encoder bagi hashAlgorithm bersarang dan bagi hash dalam MGF1. Dan pembangun CMS di FPdfCms.pas memasangkan OID signature-nya dengan byte-byte ini lewat TDerWriter.AlgIdWithParams, yang menggantinya dengan NULL ketika parameter-nya nil; itulah sebabnya psRsaPkcs1v15 dan psEcdsa sekadar mengembalikan nil dan tidak pernah terpengaruh, dan kenapa 1.2.840.113549.1.1.10, id-RSASSA-PSS, adalah satu-satunya OID signature di antara ketiganya yang membawa blok parameter sungguhan. Byte yang dihasilkan untuk profil SHA-256 itu tetap dan cukup pendek untuk diperiksa dengan mata: SEQUENCE 30 34 di luar yang memuat A0 0F di sekitar AlgorithmIdentifier SHA-256 sepanjang 15 byte, A1 1C di sekitar AlgorithmIdentifier MGF1 sepanjang 28 byte yang parameter-nya adalah AlgorithmIdentifier SHA-256 yang sama, dan A2 03 02 01 20 untuk salt-nya. Kalau dump signatureAlgorithm Anda menunjukkan 02 01 20 di level teratas SEQUENCE parameter-nya alih-alih di dalam A2, Anda sedang melihat pengodean 3.114.19

Kenapa suite ujinya tidak menangkap AlgorithmIdentifier yang malformed?

Karena uji PAdES-nya menjalankan pembangun CMS lewat signer palsu yang melaporkan sha256WithRSAEncryption dan mengembalikan nil dari GetSignatureAlgorithmParams, jadi blok parameter PSS-nya tidak pernah dibangun sama sekali di dalam uji. Itu rancangan yang wajar untuk uji yang harus berjalan tanpa certificate store, Keychain, atau token, dan ia punya titik buta dengan bentuk yang presisi: apa pun yang hanya dihasilkan backend sungguhan hanya diuji oleh backend sungguhan. Lapisan keduanya lebih menarik. PDFium Component juga menaruh AlgorithmIdentifier signature-nya, termasuk parameter-nya, di dalam atribut bertanda tangan cmsAlgorithmProtection dari RFC 6211, dan verifier membandingkan salinan itu dengan signatureAlgorithm luarnya. Kedua salinan datang dari pemanggilan yang sama, jadi keduanya cocok sempurna dan setiap pemeriksaan konsistensi internalnya lolos. Pengodeannya konsisten dengan dirinya sendiri dan salah, kategori bug yang tidak bisa dibeberkan oleh pembandingan struktur apa pun dengan dirinya sendiri, dan pelajaran yang sama dengan struktur berbeda diceritakan di signedAttrs CMS dan pengurutan DER SET OF, tempat SET yang di-hash dalam satu urutan lalu dikeluarkan dalam urutan lain tampak baik-baik saja sampai verifier asing menghitung ulang hash-nya

Yang benar-benar menangkap kelas bug ini adalah decoder yang tidak ditulis penulis encoder-nya, dijalankan atas output sungguhan dari backend sungguhan. Jalur verifikasi Windows di PDFium Component melewati CryptoAPI alih-alih reader milik library-nya sendiri, dan tanda tangan PSS yang ditolak di situ yang menuntun kembali ke parameter-nya. ASN.1 apa pun yang dikeluarkan sebuah implementasi untuk dibaca implementasi lain layak mendapat setidaknya satu round trip lewat decoder yang tidak ia kendalikan, dan semakin banyak default dan tag yang dimiliki strukturnya, semakin berharga round trip itu

Di mana ini cocok dengan sisa cerita PSS

Perbaikan ini independen dari dua tempat lain yang bisa keliru pada PSS di tanda tangan PAdES, dan memisahkan keduanya mempersingkat debugging. Backend macOS bisa mendapati key tertentu atau sistem yang lebih lama menolak PSS lalu turun ke PKCS#1 v1.5, dan AlgorithmIdentifier-nya harus mengikuti penurunan itu; itu pertanyaan kapabilitas, dibahas di menandatangani PAdES dengan identitas macOS Keychain. Backend PKCS#11 bisa menyerahkan ke token sebuah CK_RSA_PKCS_PSS_PARAMS yang tata letaknya dibaca berbeda oleh tokennya karena ketidakcocokan lebar integer; itu pertanyaan ABI, dibahas di CK_ULONG dan jebakan packing PKCS#11. Artikel ini membahas kegagalan ketiga, ketika key-nya bersedia, tokennya menghitung byte yang benar, dan DER yang mendeskripsikan hasilnya tidak sesuai RFC 4055 §3.1

Penandatangan yang mendeklarasikan PSS memikul kewajiban yang tidak pernah dimiliki penandatangan v1.5: mendeskripsikan parameternya sendiri dalam bentuk yang didekode implementasi lain menjadi tiga nilai yang sama. RFC 4055 §3.1 menetapkan tag-nya, X.690 §11.5 menetapkan field mana yang boleh muncul, dan ETSI TS 119 312 §7 menetapkan nilai yang layak dipilih. Ketiga backend komponen PDFium Delphi dirilis sebagai source, jadi isi GetSignatureAlgorithmParams di atas adalah yang bisa Anda baca, dump, dan bandingkan dengan verifier Anda sendiri alih-alih diterima begitu saja