PDF Library for Delphi (PDFlibPas) ดึงใบรับรองที่อยู่ข้างในลายเซ็น PDF ออกมาด้วยการเดิน DER ล้วน ๆ บน CMS SignedData ที่เก็บอยู่ใน /Contents โดยไม่แตะ CryptoAPI เลย ตั้งแต่ v3.539.10 การอ่านซ้อนทุกชั้นถูกผูกขอบเขตด้วย element แม่ของมัน padding เป็นศูนย์หลัง CMS ถูกตัดที่ความยาวที่ CMS ประกาศไว้ และ object identifier เข้ารหัส subidentifier แรกที่รวมกันแล้วเป็น base-128 ทั้งกฎขอบเขตและการแก้ OID ล้วนแทนที่โค้ดที่ให้คำตอบผิดโดยไม่ยก error ขึ้นมาเลย ส่วนกฎ padding ทำให้ reader ที่เข้มงวดขึ้นไม่ทิ้งลายเซ็นจริง
ฝั่งอ่านสำคัญกว่าที่ดูกว่าเยอะ tool ของ long-term validation ต้องดึงใบรับรองผู้ลงนามกับ issuer ทั้งหมดออกจากลายเซ็นที่มีอยู่ก่อนถึงจะไปหาข้อมูล revocation ได้ รายงาน audit ต้องบอกได้ว่าใครลงนาม และ build ด้วย Lazarus บน Linux ก็ไม่มีฟังก์ชัน message ของ Windows ให้ยืมใช้ parser ในสถานะแบบนี้แทบไม่เคย crash กับ input ห่อ ๆ รูปแบบความพังที่แสบจริง ๆ คือตัวนับใบรับรองที่เผลอเอาไบต์ของเพื่อนบ้านมารวมด้วย การ match ผู้ลงนามกับ field ที่ผิด หรือ OID ที่เปลี่ยนร้างเป็น OID อีกตัวโดยไม่บอกใคร pipeline ของลายเซ็นที่วางต่อบนสิ่งเหล่านี้ก็จะรายงานความเพี้ยนออกมาอย่างมั่นใจสุดขีด
ดึงใบรับรองของผู้ลงนามออกจาก PDF ที่ลงนามแล้ว
เมธอดของ TPDFlib ห้าตัวดูแลฝั่งอ่าน และทุกตัวรับ InputFile, Password, FieldName: แต่ละครั้งที่เรียกจะเปิดไฟล์แบบอ่านอย่างเดียว ตอบกลับ แล้วปิดทิ้ง GetSignatureEmbeddedCertificateCount กับ GetSignatureEmbeddedCertificateDER ไล่ใบรับรองในเซ็ตตามลำดับการเข้ารหัส GetSignatureSignerCertificateDER คืนใบรับรองที่ผลิต SignerInfo ตัวนั้น ๆ ออกมา และ GetSignatureCertificateChainLength / GetSignatureCertificateChainDER เดินจากผู้ลงนามไปหา issuer ที่ไกลที่สุดที่ลายเซ็นพามาเอง index เริ่มที่ศูนย์ เก็บผลลัพธ์ไว้ใน AnsiString นั่นแหละคือเหตุผลที่ library คืนค่ามาแบบนี้: blob DER ที่ลอดผ่าน string หรือ TStrings จะโดนแปลงชุดอักขระแล้วกลับมาเป็นขยะ
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;
สองจุดใน output แบบนี้ต้องระวัง ค่านับ 0 ไม่ใช่การวินิจฉัย: field ที่หายไป, password ผิด, blob ที่ไม่ใช่ DER และ SignedData ที่ไม่ใส่เซ็ตใบรับรองที่เป็น optional ไว้เลย ล้วนคืนค่า 0 หรือ string ว่าง จึงควร log ชื่อ field เอาไว้ข้างตัวเลขด้วย และ chain ที่จบก่อนจะถึงใบรับรอง self-issued ก็ไม่ใช่ error เหมือนกัน chain builder ใช้แค่ใบรับรองที่ฝังมากับลายเซ็นเท่านั้น ที่เหลือจึงต้องไปดึงผ่านที่อยู่ที่ GetCertificateIssuerURLs รายงานมา
ช่วงไหนของ /Contents ที่เป็น CMS จริง ๆ
เฉพาะ prefix ที่ SEQUENCE ชั้นนอกประกาศเท่านั้นที่เป็นของ CMS และ PLTrimCMSPadding ตัดทุกอย่างหลังจุดนั้นทิ้ง ผู้ลงนามต้องกัน hex string ของ /Contents ไว้ก่อน CMS จะเกิดขึ้น เพราะ /ByteRange ที่ ISO 32000-1 §12.8.1 อธิบายไว้ต้องตายตัวเป็นอย่างแรก ช่องนี้จึงถูกสำรองกว้าง ๆ และท้ายที่ไม่ได้ใช้เต็มไปด้วยศูนย์ PLTrimCMSPadding อ่าน TLV ตัวแรก บังคับว่า tag ต้องเป็น $30 แล้วคืนไบต์ถึงสุดของ element นั้น อะไรที่ไม่ได้เริ่มด้วย SEQUENCE ที่สมบูรณ์คืนกลับมาว่าง ๆ ชั้นบนสุดนี่เองคือที่เดียวที่ไบต์ท้ายส่วนเกินถูกกฎหมาย และความต่างนี้สำคัญกับหัวข้อถัดไป: กฎเข้มแบบ "element ต้องกิน buffer ให้หมด" จะทิ้งลายเซ็นจริงทุกฉบับ ขณะที่กฎหลวมที่ใช้ทุกความลึกปล่อยให้ field ซ้อนข้างในอ่านไบต์ที่ไม่ใช่ของตัวเอง
ทำไม DER reader ต้องรู้ offset สิ้นสุดของ parent
element ที่ซ้อนอยู่จะถูกต้องก็ต่อเมื่อมันจบอยู่ข้างใน parent ของมัน และการเช็คกับสุดของ buffer ไม่ได้พิสูจน์เรื่องนี้ DERReadTLV ชั้นล่างใน PDFlibASN1 ผูกขอบเขตแต่ละ element กับ string ทั้งก้อน ซึ่งเป็นเช็คที่ถูกต้องสำหรับ object ชั้นนอกสุดและผิดสำหรับทุกอย่างที่อยู่ต่ำกว่านั้น ลองนึก SignerInfo ที่ issuerAndSerialNumber ประกาศ 40 ไบต์ แต่ issuer Name ข้างในอ้างว่า 60 ทุกไบต์ยังอยู่ใน buffer ครบ reader ที่ผูกด้วย buffer จึงยอมรับ Name ไปก่อน แล้วอ่านหมายเลข serial ออกจาก digest algorithm ที่ตามมา จากนั้นเอาคู่นี้ไปเทียบกับใบรับรองที่ฝังมา ก่อน v3.539.10 CMS walker อ่านแบบนั้นเป๊ะ ๆ การแก้คือ wrapper เล็ก ๆ ที่หอบตำแหน่งสิ้นสุดของ parent ไปกับการอ่านทุกครั้ง
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
Result := False;
// ใน parent ไม่เหลืออะไรให้อ่านแล้ว: ปฏิเสธการเริ่มอ่าน
if (Offset < 1) or (Offset >= ParentEnd) then
Exit;
if not DERReadTLV(Data, Offset, Tag, Start, Len) then
Exit;
// Offset ตอนนี้อยู่เลย element ไปหนึ่งตำแหน่ง ต้องไม่ทะลุ parent
Result := Offset <= ParentEnd;
end;
// แต่ละชั้นจดจุดสิ้นสุดของตัวเองแล้วส่งต่อลงไป:
// OuterEnd := end of ContentInfo (RFC 5652 section 3)
// ExplicitEnd := end of content [0] EXPLICIT
// ContentEnd := end of SignedData (RFC 5652 section 5.1)
// SignerEnd / InnerEnd for SignerInfo and issuerAndSerialNumber
ยูนิต PDFlibCMSRead ตอนนี้ส่งจุดสิ้นสุดพวกนี้ลอดผ่าน ContentInfo, wrapper [0] EXPLICIT, field ของ SignedData จนถึง signerInfos, SignerIdentifier ทั้งแบบ issuerAndSerialNumber และแบบ [0] subjectKeyIdentifier (RFC 5652 §5.3) และ field ของ tbsCertificate ที่อ่านจากใบรับรองที่ฝังมาแต่ละใบตอน match ผู้ลงนาม ข้างในเซ็ตใบรับรองกับเซ็ต signerInfos element ที่วิ่งทะลุสุดของเซ็ตจะหยุด loop ทิ้ง: PLExtractCMSCertificates คืนเฉพาะใบรับรองที่รับไปแล้ว และไม่มีวันเอาไบต์ crls หรือ signerInfos ที่ตามมาไปแปะต่อท้ายใบสุดท้าย การ match แบบ issuer-and-serial ยังต้องได้ครบทั้งสองข้างด้วย เพราะหมายเลข serial เป็น unique เฉพาะภายใน issuer เดียว
ทำไม 2.999.3 ถึงกลายเป็น 1.15.3
arc สองตัวแรกของ OID รวมกันเป็น subidentifier เดียว ไม่ใช่หนึ่งไบต์ และ subidentifier ตัวนี้ถูกเข้ารหัสแบบ base-128 เหมือน arc อื่นทุกตัว X.690 §8.19.4 นิยามมันว่า 40 * arc1 + arc2 แต่ DER_OID รุ่นก่อนเขียนค่านี้ด้วย Byte(...) ซึ่งถูกต้องแค่ถึง 127 ที่เท่ากับ 2.47 พอเป็น 2.999 ผลรวมคือ 1079 การ cast เป็นไบต์เหลือ 55 และ 55 ถอดรหัสกลับมาเป็น 1.15 ตัวระบุจึงเงียบ ๆ ชี้ไปคนละกิ่งของต้นไม้เฉยเลย ค่าตั้งแต่ 128 ถึง 255 พังแบบอื่น: ปล่อยไบต์เดียวที่ติด continuation bit ออกไปแล้วกลืน arc ถัดไปหาย ตัวระบุ PKI ส่วนใหญ่ (1.2.840..., 2.5.29..., 0.4.0...) ไม่เคยไปถึงเส้นแบ่ง บั๊กเลยอยู่รอดมาได้ แต่ arc joint-iso-itu-t ตั้งแต่ 2.48 ขึ้นไปคือพวกที่แตะเส้นนั้นจริง ๆ DER_OID รับใช้ทั้ง encoder ของ signed attributes และตัว match ใน DERFindExtensionByOID กับการเช็ค content-type ของ SignedData encoding ที่ผิดจึงทำลายทั้งตอนเขียนและตอนค้นหาพร้อมกัน
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.
ค่าที่รวมแล้วถูกถือไว้ใน UInt64 อย่างมีเจตนา DER_OID parse arc ลง Int64 arc ตัวที่สองตามกฎหมายจึงใหญ่ได้ถึง Int64.MaxValue และการบวก 80 เมื่อ arc1 = 2 ล้นเลข 64 บิตแบบมีเครื่องหมาย UInt64 หอบ Int64.MaxValue + 80 ไว้ได้โดยไม่วนกลับ และ buffer ชั่วคราวสิบไบต์ก็จุกลุม 7 บิตสิบก้อนที่ค่า 64 บิตหนึ่งต้องการ test vector ที่สมควรเก็บไว้คือพวกที่อยู่คนละฝั่งของเส้นแบ่ง: 2.47 ต้องยังเป็นไบต์เดียว และ 2.48 ต้องกลายเป็นสองไบต์
CMS walker ฝั่งอ่านรับประกันอะไร
PDFlibCMSRead รับประกันโครงสร้างเท่านั้น ไม่รวมอย่างอื่น: มันคืนไบต์ที่นั่งอยู่ตรงที่ RFC 5652 บอกไว้ และไม่ verify ทั้งลายเซ็น ทั้ง digest ทั้งช่วงอายุใช้งาน walker รับเฉพาะ DER ดังนั้น DERReadTLV ปฏิเสธความยาวแบบ indefinite กับหมายเลข tag หลายไบต์ และ CMS ที่เข้ารหัส BER มาจากผู้ลงนามที่ไม่เป็นไปตามมาตรฐานจะรายงานใบรับรองเป็นศูนย์ ดีกว่าเดาครึ่ง ๆ กลาง ๆ ใบ certificate แบบ attribute กับทางเลือกอื่นของ CertificateChoices ถูกข้ามไป เพราะไม่มีส่วนไหนปลายทางใช้มันได้ การ verify เชิงเข้ารหัสยังอยู่กับโค้ดที่เป็นเจ้าของมัน ซึ่งเริ่มที่การเช็คครอบคลุมไบต์ที่เล่าไว้ในการลงนาม PAdES กับการตรวจ ByteRange ใน Delphi และไปต่อที่การจำแนกว่าอะไรเปลี่ยนไปหลัง PDF ถูกลงนาม
บทเรียนที่กว้างกว่านั้นใช้กับ binary format ไหนก็ได้: "อยู่ใน buffer" เป็นสมบัติด้าน memory safety "อยู่ใน parent" เป็นสมบัติด้านความถูกต้อง และ parser ต้องมีทั้งคู่ แนวคิดเรื่องความยาวที่มุ่งร้ายแบบเดียวกันนี้ทอดยาวไปในการทำให้ Pascal PDF parser แข็งแรงขึ้นต้านไฟล์ประสงค์ร้าย ส่วน API ดึงใบรับรอง สร้าง chain และ long-term validation ที่คุยกันมาทั้งหมดมาพร้อมlosLab PDF Library for Delphi รองรับทั้ง Delphi, C++Builder และ Lazarus