Artikel Teknis

Parsing CMS Signature di Delphi: Batas DER dan Arc OID

PDF Library for Delphi (PDFlibPas) mengeluarkan sertifikat di dalam signature PDF lewat penelusuran DER murni atas CMS SignedData yang tersimpan di /Contents, tanpa CryptoAPI sama sekali. Sejak v3.539.10 setiap pembacaan bersarang dibatasi oleh elemen parent-nya, padding nol setelah CMS dipotong pada panjang yang dideklarasikan CMS, dan object identifier mengenkode subidentifier pertama gabungannya dalam base-128. Aturan batas dan perbaikan OID sama-sama menggantikan kode yang menghasilkan jawaban salah tanpa melempar error, dan aturan padding menjaga reader yang lebih ketat dari menolak signature yang asli

Sisi pembacaannya lebih penting dari kelihatannya. Tooling long-term validation harus menarik sertifikat signer beserta issuer-nya keluar dari signature yang sudah ada sebelum bisa mengambil data revocation, laporan audit harus menyebut siapa yang menandatangani, dan build Lazarus di Linux tak punya fungsi pesan Windows untuk diandalkan. Parser di posisi itu jarang crash pada input yang buruk. Mode kegagalan yang menyakitkan adalah jumlah sertifikat yang menyertakan byte milik tetangganya, kecocokan signer terhadap field yang salah, atau OID yang diam-diam berubah menjadi OID lain. Signature pipeline yang dibangun di atasnya melaporkan omong kosong yang terdengar percaya diri

Membaca sertifikat signer dari PDF yang ditandatangani

Lima method TPDFlib menutup sisi pembacaan, dan semuanya menerima InputFile, Password, FieldName: setiap panggilan membuka file secara read-only, menjawab, lalu menutupnya lagi. GetSignatureEmbeddedCertificateCount dan GetSignatureEmbeddedCertificateDER menghitung sertifikat dalam certificates set menurut urutan encoding, GetSignatureSignerCertificateDER mengembalikan sertifikat yang menghasilkan SignerInfo tertentu, dan GetSignatureCertificateChainLength / GetSignatureCertificateChainDER menelusuri dari signer itu menuju issuer terjauh yang dibawa signature itu sendiri. Indeksnya zero-based. Simpan hasilnya di AnsiString — memang karena itu library mengembalikannya dalam bentuk itu: blob DER yang dialirkan lewat string atau TStrings melewati konversi character set dan kembali dalam keadaan rusak

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;

Dua hal di output itu perlu diwaspadai. Count 0 bukan diagnosis: field yang hilang, password yang salah, blob yang bukan DER, dan SignedData yang sekadar tidak menyertakan certificates set opsional semuanya kembali sebagai 0 atau string kosong, jadi catat nama field di sebelah angkanya. Dan chain yang berakhir sebelum sertifikat self-issued juga bukan error. Chain builder hanya memakai sertifikat yang tertanam di signature, jadi issuer-issuer yang tersisa harus diambil lewat alamat yang dilaporkan GetCertificateIssuerURLs

Seberapa banyak /Contents yang benar-benar CMS?

Hanya prefiks yang dideklarasikan SEQUENCE terluar yang menjadi bagian CMS, dan PLTrimCMSPadding memotong semua yang berada setelahnya. Signer memesan hex string /Contents sebelum CMS itu ada, karena /ByteRange yang dijelaskan ISO 32000-1 §12.8.1 harus dikunci lebih dulu, jadi slotnya dibuat berukuran lega dan ekor yang tak terpakai berisi nol. PLTrimCMSPadding membaca TLV pertama, mensyaratkan tag $30, dan mengembalikan byte sampai akhir elemen itu; apa pun yang tak diawali SEQUENCE yang well-formed kembali kosong. Level teratas itulah satu-satunya tempat di mana byte ekor legal, dan pembedaan ini penting untuk bagian berikutnya: aturan ketat "elemen harus menghabiskan seluruh buffer" akan menolak setiap signature dunia nyata, sementara aturan longgar yang diterapkan di setiap kedalaman membiarkan field bersarang membaca byte yang bukan miliknya

PLTrimCMSPadding milik PDFlibPas membaca TLV pertama dari hex string /Contents yang dipesan, mensyaratkan tag $30 dan memotong padding nol pada panjang yang dideklarasikan SEQUENCE terluar, serta mengembalikan hasil kosong saat buffer tak diawali SEQUENCE yang well-formed
Byte ekor hanya legal di level teratas, tempat slot yang dipesan harus tetap diam untuk /ByteRange — pembacaan yang lebih dalam memakai aturan berbatas parent

Kenapa DER reader butuh offset akhir dari parent?

Elemen bersarang hanya valid kalau ia berakhir di dalam parent-nya, dan pengecekan terhadap akhir buffer tidak membuktikan hal itu. DERReadTLV level rendah di PDFlibASN1 membatasi setiap elemen terhadap string utuh, yang merupakan pengecekan yang benar untuk objek terluar dan yang salah untuk semua yang ada di bawahnya. Bayangkan sebuah SignerInfo yang issuerAndSerialNumber-nya mendeklarasikan 40 byte sementara issuer Name di dalamnya mengklaim 60. Setiap byte masih berada di buffer, jadi reader berbatas buffer menerima Name itu, membaca serial number dari digest algorithm yang menyusul, lalu membandingkan pasangan itu terhadap sertifikat yang tertanam. Sebelum v3.539.10 CMS walker membaca persis seperti itu. Perbaikannya adalah wrapper kecil yang membawa posisi akhir parent ke setiap pembacaan

PDFlibPas membatasi setiap pembacaan DER bersarang dengan elemen parent-nya: issuer Name 60 byte di dalam issuerAndSerialNumber 40 byte diterima DERReadTLV lama yang berbatas buffer, yang lalu membaca serial number dari digestAlgorithm, sementara ReadTLVWithin menolak elemen mana pun yang berakhir melewati ParentEnd
Di dalam buffer itu memory safety, di dalam parent itu correctness — PDFlibPas merambatkan offset akhir parent ke setiap level CMS supaya length yang jahat tak bisa meminjam byte milik tetangga
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // tak ada sisa lagi di dalam parent: menolak memulai pembacaan
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // Offset kini berada satu setelah elemen; ia tak boleh melewati parent
  Result := Offset <= ParentEnd;
end;

// setiap level mencatat akhirnya sendiri dan mewariskannya ke bawah:
//   OuterEnd    := akhir ContentInfo         (RFC 5652 bagian 3)
//   ExplicitEnd := akhir content [0] EXPLICIT
//   ContentEnd  := akhir SignedData          (RFC 5652 bagian 5.1)
//   SignerEnd / InnerEnd untuk SignerInfo dan issuerAndSerialNumber

Unit PDFlibCMSRead kini merambatkan ujung-ujung itu melalui ContentInfo, wrapper [0] EXPLICIT, field-field SignedData sampai signerInfos, SignerIdentifier dalam kedua bentuknya, issuerAndSerialNumber maupun [0] subjectKeyIdentifier (RFC 5652 §5.3), serta field tbsCertificate yang dibaca dari setiap sertifikat tertanam saat mencocokkan signer. Di dalam certificates set dan signerInfos set, elemen yang melewati akhir set menghentikan loop: PLExtractCMSCertificates mengembalikan sertifikat yang sudah diterimanya dan tak pernah menempelkan byte crls atau signerInfos berikutnya ke sertifikat terakhir. Pencocokan issuer-and-serial juga mensyaratkan kedua belahannya, karena serial number hanya unik di dalam satu issuer

Kenapa 2.999.3 keluar sebagai 1.15.3?

Dua arc pertama sebuah OID bergabung menjadi satu subidentifier, bukan satu byte, dan subidentifier itu dienkode base-128 seperti arc lainnya. X.690 §8.19.4 mendefinisikannya sebagai 40 * arc1 + arc2; DER_OID yang lama menulis nilai itu dengan Byte(...), yang hanya benar sampai 127, yaitu nilai 2.47. Untuk 2.999 jumlahnya 1079, cast byte menyisakan 55, dan 55 terdekode sebagai 1.15, jadi identifier itu diam-diam menyebut cabang lain dari tree. Nilai 128 sampai 255 gagal dengan cara berbeda: satu byte terbit dengan continuation bit terpasang yang menelan arc berikutnya. Sebagian besar identifier PKI (1.2.840..., 2.5.29..., 0.4.0...) tak pernah menyentuh batas itu, dan itulah kenapa bug ini lolos; arc joint-iso-itu-t dari 2.48 ke atas justru kena. DER_OID melayani encoder untuk signed attributes sekaligus matcher di DERFindExtensionByOID dan pengecekan content-type SignedData, jadi encoding yang salah merusak penulisan dan lookup sekaligus

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.
DER_OID milik PDFlibPas menggabungkan dua arc OID pertama sebagai 40 * arc1 + arc2 dan mengenkode jumlahnya base-128 dalam UInt64, jadi 2.999.3 menjadi 06 03 88 37 03, sementara cast Byte lama menyimpan 55 dan diam-diam mendekode identifier sebagai 1.15.3
Sebagian besar arc PKI tak pernah menyentuh batas itu, dan itulah kenapa bug ini lolos — arc joint-iso-itu-t dari 2.48 ke atas butuh dua byte, dan test menjaga 2.47 serta 2.48 di kedua sisinya

Nilai gabungan itu sengaja disimpan dalam UInt64. DER_OID mem-parse arc ke Int64, jadi arc kedua yang legal bisa sebesar Int64.MaxValue, dan menambahkan 80 untuk arc1 = 2 membuat integer 64-bit bertanda overflow. UInt64 membawa Int64.MaxValue + 80 tanpa wrap-around, dan scratch buffer sepuluh byte menampung sepuluh grup 7-bit yang dibutuhkan nilai 64-bit. Test vector yang layak disimpan adalah yang berada di kedua sisi batas: 2.47 harus tetap satu byte, dan 2.48 harus menjadi dua

Apa yang dijamin CMS walker di sisi pembacaan?

PDFlibCMSRead menjamin struktur dan tidak lebih: ia mengembalikan byte yang berada di posisi yang kata RFC 5652 seharusnya, tanpa memverifikasi signature, digest, maupun validity period. Walker ini hanya menerima DER, jadi DERReadTLV menolak indefinite length dan nomor tag multi-byte, dan CMS terenkode BER dari signer yang tak patuh melaporkan nol sertifikat alih-alih tebakan sebagian. Attribute certificates dan alternatif CertificateChoices lainnya dilewati karena tak ada di hilir yang bisa memakainya. Verifikasi kriptografi tetap di tangan kode yang memilikinya, yang dimulai dari pengecekan cakupan byte yang dijelaskan di penandatanganan PAdES dan validasi ByteRange di Delphi dan berlanjut ke mengklasifikasi apa yang berubah setelah PDF ditandatangani

Pelajaran yang lebih luas berlaku untuk format biner mana pun: "di dalam buffer" adalah properti memory-safety, "di dalam parent" adalah properti correctness, dan parser butuh keduanya. Cara berpikir yang sama soal length yang jahat mengalir di menghardening parser PDF Pascal terhadap file berbahaya. API ekstraksi sertifikat, pembangunan chain, dan long-term validation yang dibahas di sini tersedia di losLab PDF Library for Delphi, untuk Delphi, C++Builder dan Lazarus