บทความเทคนิค

วิเคราะห์การเปลี่ยน PDF หลังลงลายเซ็นด้วย PDFium ใน Delphi

เพื่อหาว่าอะไรเปลี่ยนไปใน PDF หลังถูกลงลายเซ็น PDFium Component สำหรับ Delphi และ Lazarus มี TPdf.AnalyzeSignatureRevisions ตัววิเคราะห์การเปลี่ยนแปลง revision หลังลงลายเซ็นที่สร้าง incremental revision ทุกตัวขึ้นใหม่จาก byte ไฟล์ต้นฉบับ ให้คะแนนการเปลี่ยน object ยุคหลังแต่ละตัวเทียบกับกฎ DocMDP กับ FieldMDP ของลายเซ็นนั้น และรายงาน object ที่ถูกนิยามแอบแฝงเป็นความเสี่ยงแยกต่างหาก สถานการณ์ที่มันลงล่าคุ้นเคยกับคนจัดการสัญญาทุกคน: ฟอร์มที่ certify แล้วถูกส่งออกไป กลับมาพร้อม incremental save อีกสองรอบ และทุกลายเซ็นก็ยัง verify ผ่าน นั่นคือเรื่องปกติ เพราะลายเซ็นครอบแค่ byte ของ revision ตัวเอง คำถามจริงคือการ save ยุคหลังพวกนั้นได้รับอนุญาตหรือเปล่า และเครื่องหมายถูกสีเขียวบนลายเซ็นไม่ได้ตอบเรื่องนี้

ทำไม API signature ของ PDFium ถึงไม่บอกได้ว่าอะไรเปลี่ยนหลังลงลายเซ็น

API signature ของ PDFium ไม่บอกการเปลี่ยนหลังลงลายเซ็นได้ เพราะมันอ่านแค่ signature dictionary: /Contents, /ByteRange, /SubFilter และค่า permission ของ DocMDP PDFium ไม่มีกราฟ incremental revision, ไม่ parse พารามิเตอร์ transform ของ FieldMDP และไม่มี diff ระดับ object ระหว่าง revision ตัววิเคราะห์ใน FPdfPades.pas จึงลงมือบน byte ดิบตรง ๆ แทน มีผลในทางปฏิบัติที่คุณควรออกแบบรอบมัน TPdf.AnalyzeSignatureRevisions อ่าน byte ที่เก็บไว้ตอนโหลดเอกสาร ไม่ใช่สำเนาที่ผลิตโดย SaveAs เพราะไฟล์ที่ถูกเขียนใหม่สูญเสียโครงสร้าง revision ที่กำลังจะถูกวิเคราะห์ไปแล้ว ถ้าเอกสารมาจากแหล่งแบบ progressive ที่ยังดาวน์โหลดไม่จบ รายงานจะคืน SourceStatus = pvssIncomplete กับ Status = prasIndeterminate แทนที่จะวิเคราะห์ไฟล์ที่ถูกตัดขาด

สร้างขอบเขต revision ใหม่จาก startxref, xref stream และ /Prev

ตัววิเคราะห์สร้างขอบเขต revision ขึ้นใหม่โดยไล่ startxref ทุกตัวย้อนกลับผ่าน xref table แบบคลาสสิก, cross-reference stream, entry /XRefStm แบบ hybrid-reference และห่วงโซ่ /Prev ตามที่นิยามไว้สำหรับ incremental update ใน ISO 32000-1 §7.5.6 กับ §7.5.8 ความยาวที่ลายเซ็นแต่ละตัวครอบคลุมคือจุดจบของ span ByteRange อันที่สอง และตัววิเคราะห์ map ความยาวนั้นไปยัง revision ที่ xref section ของมันตกอยู่ เมื่อไม่มี revision ตรงเลย ลายเซ็นจะได้ prrCoveredRevisionNotFound กับสถานะ Indeterminate สถานะของทุก object จะถูกเล่นซ้ำขึ้นถึง revision ที่ถูกครอบ และ entry xref ยุคหลังแต่ละตัวถูกเทียบกับสถานะนั้น จุดนี้สำคัญกว่าที่ฟังผ่าน ๆ: writer บางตัวพูดซ้ำตาราง xref เต็ม ๆ ทุกครั้งที่ incremental save entry ที่ยังชี้ไปที่ object ตัวเดิมที่ไม่เปลี่ยนจะถูกข้าม แทนที่จะถูกรายงานเป็นการแก้ไข ถ้าไม่มีการเทียบนี้ การกรอกฟอร์มที่ถูกกฎหมายสุด ๆ ก็จะจมอยู่ในการเปลี่ยนแปลงปลอมหลายร้อยรายการ

AnalyzeSignatureRevisions สร้าง incremental revision ขึ้นใหม่จาก byte ดิบของ PDF ใน Delphi อย่างไร: ByteRange ของลายเซ็นจบอยู่ภายใน revision ที่ถูกครอบ, ห่วงโซ่ Prev ของ xref เดินย้อนผ่านการ save ยุคหลังทุกครั้ง, สถานะ object ถูกเล่นซ้ำขึ้นถึง revision ที่ถูกครอบ และ entry ที่พูดซ้ำแต่ไม่เปลี่ยนถูกข้ามแทนที่จะถูกรายงานเป็นการเปลี่ยนแปลง
ลายเซ็นครอบแค่ byte ของ revision ตัวเอง ตัววิเคราะห์จึง map span ByteRange อันที่สองไปยัง revision หนึ่ง แล้วให้คะแนน entry xref ยุคหลังแต่ละตัวเทียบกับสถานะ object ที่เล่นซ้ำแล้ว

นิยามแบบ shadow คือเคสที่ควรได้รับความสนใจมากที่สุด body ของ object ที่โผล่อยู่ใน byte range ของ revision ยุคหลังแต่ไม่ถูกอ้างถึงโดย xref ของ revision นั้น viewer ธรรมดามองไม่เห็น แต่มันคือการวางกับดักแบบที่ shadow attack พึ่งพาพอดี: เนื้อหาที่ซ่อนไว้ถูกฝังก่อนหรือหลังลงลายเซ็น แล้วถูกเปิดใช้ภายหลังด้วยการพลิก reference AnalyzePadesSignatureRevisionsBytes จด object แบบนี้เป็นการเปลี่ยนแปลงที่ไม่เป็นทางการด้วย IsAuthoritative = False ให้คะแนนเป็น prdSuspicious ไม่ว่าระดับ permission จะเป็นอะไร และเติม prrUnreferencedObjectDefinition ลงชุดความเสี่ยง ความเสี่ยงที่เกี่ยวพันอีกสองตัวครอบเทคนิคเชิงโครงสร้างอื่น: prrDuplicateObjectDefinition ยิงเมื่อ xref section เดียวลิสต์ object เดียวกันเกินหนึ่งครั้ง และ prrSignatureObjectRedefined ยิงเมื่อ revision ยุคหลังนิยาม object ลายเซ็นที่มีอยู่ใหม่

นิยาม object แบบ shadow ภายใน byte range ของ revision PDF ยุคหลัง: body ของ object มีตัวตนแต่ไม่มี entry xref ใดอ้างถึง viewer จึงไม่เคยแสดงมัน AnalyzeSignatureRevisions ใน PDFium Component จดมันเป็น non-authoritative, ให้คะแนน prdSuspicious และยก prrUnreferencedObjectDefinition ควบคู่กับความเสี่ยง duplicate กับ signature ที่ถูกนิยามใหม่
เนื้อหาที่ซ่อนไว้ถูกฝังก่อนหรือหลังลงลายเซ็น แล้วเปิดใช้ภายหลังด้วยการพลิก reference นี่คือเหตุผลที่ body ที่ไร้การอ้างถึงถูกให้คะแนน suspicious ไม่ว่าระดับ permission ของ DocMDP จะเป็นอะไร
uses
  SysUtils, TypInfo, PDFium, FPdfPades;

const
  ShadowTag: array[Boolean] of string = ('', ' (shadow)');
var
  Pdf: TPdf;
  Report: TPadesRevisionAnalysisReport;
  i, j: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract-returned.pdf';
    Pdf.Active := True;
    Report := Pdf.AnalyzeSignatureRevisions;
    Writeln('Revisions: ', Report.RevisionCount,
      '  Signatures: ', Report.SignatureCount,
      '  Overall: ', GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus),
        Ord(Report.Status)));
    for i := 0 to High(Report.Signatures) do
      with Report.Signatures[i] do
      begin
        Writeln(Format('Signature %d covers revision %d, %d later, P=%d, FieldMDP=%s',
          [SignatureIndex, CoveredRevisionIndex, LaterRevisionCount,
           DocMdpPermission,
           GetEnumName(TypeInfo(TPadesFieldMdpAction), Ord(FieldMdpAction))]));
        for j := 0 to High(Changes) do
          Writeln(Format('  rev %d  obj %d  %s -> %s%s',
            [Changes[j].RevisionIndex, Changes[j].ObjectNumber,
             GetEnumName(TypeInfo(TPadesRevisionChangeKind), Ord(Changes[j].Kind)),
             GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Changes[j].Decision)),
             ShadowTag[not Changes[j].IsAuthoritative]]));
      end;
  finally
    Pdf.Free;
  end;
end;

DocMDP กับ FieldMDP ถูกบังคับใช้ต่อลายเซ็นอย่างไร

DocMDP กับ FieldMDP ถูกบังคับใช้แยกกันต่อลายเซ็น ที่ revision ที่ลายเซ็นตัวนั้นครอบคลุมเอง ลายเซ็น certify กับลายเซ็น approval ที่มาทีหลังในไฟล์เดียวกันจึงอาจตัดสินการแก้เดียวกันต่างกันได้ object ยุคหลังทุกตัวถูกจัดหมวดเป็น TPadesRevisionChangeKind ก่อน จาก entry /Type, /Subtype กับ /FT และจากบทบาทที่มันเล่นในกราฟหน้า, ฟอร์ม, annotation และ DSS สิ่งใดพก /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia หรือ /EmbeddedFile กลายเป็น prckActiveContent การตัดสินแล้วเดินตาม ISO 32000-1 §12.8.2.2: ที่ P=1 ห้ามทุกอย่างยกเว้นข้อมูล cross-reference กับวัสดุตรวจสอบ, P=2 ยอมให้กรอกฟอร์มและลายเซ็นต่อ แต่ปฏิเสธการแก้ annotation, P=3 ยอมให้ annotation ด้วย เนื้อหาหน้า, โครงสร้างเอกสาร, metadata, active content กับ object ที่ถูกลบถูกห้ามใต้ระดับ DocMDP ใด ๆ และถูกให้คะแนน prdSuspicious เมื่อลายเซ็นไม่พก DocMDP เลย เพราะลายเซ็น approval ไม่ได้ห้ามอะไรตามตัวอักษร แต่ผู้อ่านก็ไม่เห็นสิ่งที่ถูกลงลายเซ็นไว้อีกต่อไป

FieldMDP จาก ISO 32000-1 §12.8.2.4 ยกระดับการตัดสินฟิลด์ฟอร์มให้คมขึ้นอีก pfmaAll ล็อกทุกฟิลด์, pfmaInclude ล็อกเฉพาะฟิลด์ที่ลิสต์ และ pfmaExclude ล็อกทุกอย่างยกเว้นฟิลด์ที่ลิสต์ เพื่อ apply Include หรือ Exclude ตัววิเคราะห์ resolve ฟิลด์ที่เปลี่ยนแต่ละตัวให้เป็นชื่อ fully qualified ผ่านห่วงโซ่ /Parent แล้วเทียบกับลิสต์ล็อกแบบตรงเป๊ะ จงลิสต์ชื่อฟิลด์ปลายทาง อย่าหวังว่าชื่อพ่อจะครอบลูก เมื่อชื่อ resolve ไม่ได้หรือ transform ใช้ action ที่ parser ไม่รู้จัก การเปลี่ยนจะกลายเป็น prdIndeterminate และ prrFieldMdpUnresolved ถูกยกขึ้น การตัดสินต่อการเปลี่ยนแล้วถูกรวมขึ้นแบบเลวก่อน โดย Suspicious จัดอยู่เหนือ Disallowed, Disallowed เหนือ Indeterminate และ Indeterminate เหนือ Allowed object shadow หนึ่งตัวจึงชนะการอัปเดตฟิลด์ที่ชอบด้วยกฎหมายกี่ตัวก็ได้

pipeline การให้คะแนนที่ AnalyzeSignatureRevisions ใช้กับการเปลี่ยนหลังลายเซ็นแต่ละตัวใน Delphi: TPadesRevisionChangeKind จาก entry Type กับ Subtype, การตัดสิน DocMDP ที่ revision ที่ถูกครอบตั้งแต่ P=1 ถึง P=3, การเช็กล็อก FieldMDP บนชื่อฟิลด์ fully qualified และการรวมขึ้นแบบเลวก่อนจาก prdSuspicious ลงไปถึง prdAllowed
object shadow หนึ่งตัวชนะการอัปเดตฟิลด์ที่ชอบด้วยกฎหมายกี่ตัวก็ได้ เพราะ Suspicious จัดอยู่เหนือ Disallowed, Indeterminate และ Allowed ขณะที่ความเสี่ยงบางตัวถูกจดไว้ข้างสถานะโดยไม่ลดสถานะ

ทำไมบางการเปลี่ยนถึงกลับมาเป็น Indeterminate แทนที่จะปลอดภัย

การเปลี่ยนกลับมาเป็น Indeterminate ทุกครั้งที่ตัววิเคราะห์พิสูจน์ไม่ได้ว่ามันได้รับอนุญาต เพราะในการเช็กลายเซ็น สิ่งที่ไม่รู้ห้ามถูกรายงานว่าอนุญาตเด็ดขาด มีกรณีทั่วไปหนึ่งเคสที่ถูกจัดการอย่างแม่นยำแทน: long-term validation เติม /DSS แล้วเขียน catalog ใหม่ ซึ่งไม่งั้นจะนับเป็นการแก้เชิงโครงสร้างใต้ P=1 ตัววิเคราะห์ strip /DSS กับ /Extensions ออกจาก dictionary catalog เก่ากับใหม่แล้วเทียบส่วนที่เหลือ เมื่อไม่มีอย่างอื่นต่างกัน การเขียนใหม่ถือเป็นการอัปเดตวัสดุตรวจสอบและได้รับอนุญาต B-LT กับ B-LTA augmentation จึงไม่ทำลายลายเซ็น certify ส่วนช่องว่างอื่นถูกเปิดไว้โดยตั้งใจ entry Type-2 ใน cross-reference stream ชี้เข้าไปใน object stream ที่บีบอัดไว้ และตัววิเคราะห์ไม่ขยาย object stream ภายในขอบเขตความปลอดภัยนี้ การเปลี่ยนพวกนั้นจึงโผล่มาเป็น prckCompressedObject พร้อม prrCompressedObjectUnresolved ถูกห้ามใต้ P=1 และ Indeterminate ในกรณีอื่น เพดานตายตัว 1024 revision, object number 1,000,000 ตัว และการเปลี่ยนที่ถูกรายงาน 2,000,000 รายการให้ prrResourceLimitExceeded และห่วงโซ่ xref ที่พังให้ prrMalformedRevisionChain ทั้งคู่จบที่ Indeterminate ไม่มีวันจบเป็น pass

const
  StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
    prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
    prrSignatureObjectRedefined, prrResourceLimitExceeded];

function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
  // ความเสี่ยงบางตัวถูกจดโดยไม่แก้ Status จงเช็กพวกมันก่อน
  if R.Risks * StructuralRisks <> [] then
    Exit('review: structural risk in the revision chain');
  case R.Status of
    prasNoLaterChanges: Result := 'accept: nothing was added after signing';
    prasAllowed:        Result := 'accept: every later change is permitted';
    prasDisallowed:     Result := 'reject: a change violates DocMDP or FieldMDP';
    prasSuspicious:     Result := 'reject: shadow or unconstrained content change';
    prasIndeterminate:  Result := 'review: the analyzer could not decide';
  else
    Result := 'not checked: no signatures or no original bytes';
  end;
end;

ลำดับใน gate นั้นตั้งใจให้เป็นแบบนั้น prrDuplicateObjectDefinition ถูกเติมลงชุดความเสี่ยงโดยตัวมันเองไม่ลด Status และ transform FieldMDP ที่ parse ไม่ได้กระทบสถานะเฉพาะเมื่อฟิลด์ฟอร์มเปลี่ยนจริง gate ที่จ้องแค่ Status จึงอาจพลาดหลักฐานที่รายงานพกไว้แล้ว จงจำสิ่งที่รายงานไม่อ้างด้วย TPadesRevisionAnalysisReport ไม่พูดอะไรเลยว่าลายเซ็น CMS ถูกต้องทางเข้ารหัสหรือไม่ หรือ certificate ผู้ลงลายเซ็น chain ไปถึง root ที่คุณเชื่อถือหรือเปล่า การวิเคราะห์ revision ตอบคำถามว่าเกิดอะไรขึ้นหลังลงลายเซ็น มันจึงควรอยู่เคียงข้างการตรวจโครงสร้างกับ trust ไม่ใช่มาแทนที่พวกมัน

เขียน seed value กับล็อก MDP ตอนลงลายเซ็น

กฎชุดเดิมเขียนขึ้นได้ตอนลงลายเซ็นผ่าน TPadesSignatureFieldOptions ซึ่งเป็น member FieldOptions ของทั้ง TPadesSignOptions และ TPadesRemoteSignOptions PDFium สร้าง widget ได้แต่เขียน /SV, /Lock, transform แบบ FieldMDP หรือ DocMDP หรือ dictionary /Perms ของ catalog ไม่ได้ ตัวเขียน PAdES แบบขยายของ component เองจึงผลิต object พวกนี้ใน xref update เดียวกับลายเซ็น FieldName ตั้งชื่อฟิลด์ราก, RequiredSeedValues กลายเป็น bit /Ff ของ seed-value dictionary ตามที่บรรยายใน ISO 32000-1 §12.7.4.5, Reasons, LegalAttestations กับ AcceptableCertificates จำกัดตัวเลือกของผู้ลงลายเซ็นยุคหลัง, LockAction คู่กับ LockFields เขียน /SigFieldLock แบบ indirect และ CertificationPermission ตั้งแต่ 1 ถึง 3 เปลี่ยนลายเซ็นให้เป็นลายเซ็น certify transform แบบ DocMDP กับ FieldMDP ทั้งคู่ลงไปใน array /Reference เดียวบน signature value โดยแต่ละตัวมี /Data ชี้ที่ catalog

var
  Options: TPadesSignOptions;
begin
  Options := TPadesSignOptions.Default;
  Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
  Options.Reason := 'Approved for release';
  Options.FieldOptions.FieldName := 'Certification';
  Options.FieldOptions.CertificationPermission := 2;   // กรอกฟอร์มกับลงลายเซ็นเท่านั้น
  Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
  Options.FieldOptions.LockAction := pfmaInclude;      // ล็อกเฉพาะฟิลด์พวกนี้
  SetLength(Options.FieldOptions.LockFields, 2);
  Options.FieldOptions.LockFields[0] := 'Total';
  Options.FieldOptions.LockFields[1] := 'IBAN';
  if not Pdf.SignPades('contract-certified.pdf', Options) then
    Writeln('Signing failed');
end;

มีรายละเอียดสองสามข้อที่พลาดง่ายถ้าคุณเขียนส่วนนี้เอง /Perms /DocMDP ของ catalog ต้องอ้างถึง signature value dictionary ไม่ใช่ widget annotation ตัวเขียนจึงเก็บ signature value ไว้เป็น indirect object ของตัวเองด้วยเหตุผลนี้ dictionary /Perms ที่มีอยู่อาจพกสิทธิ์การใช้งาน /UR3 อยู่แล้ว ตัวเขียนจึง copy มันแล้วแทรก /DocMDP เข้าไปแทนที่จะเขียนทับ ตาม permissions dictionary ใน ISO 32000-1 §12.8.4 เอกสารที่พก /DocMDP อยู่แล้วจะปฏิเสธลายเซ็น certify ตัวที่สองด้วย EPadesCrypto options ที่ขัดแย้งกันเองก็เช่นกัน: ล็อกแบบ Include หรือ Exclude ที่ไม่มีชื่อฟิลด์, ล็อกแบบ All ที่พกรายการฟิลด์, legal attestation บนลายเซ็นที่ไม่ใช่ certify หรือจุดในชื่อฟิลด์ราก การลงลายเซ็นระยะไกลเพิ่มกฎหนึ่งข้อ เพราะ certificate ผู้ลงลายเซ็นยังไม่รู้จักตอน PreparePadesRemoteSignature รัน: การตั้ง CertificateRequired ที่นั่นเรียกร้องรายการ AcceptableCertificates อย่างชัดเจน ขณะที่การลงลายเซ็นในเครื่อง fallback ไปที่ certificate ผู้ลงลายเซ็นที่ resolve แล้วได้

การวิเคราะห์ revision เติมเต็มกล่องเครื่องมือลายเซ็น ไม่ใช่มาแทนส่วนใดของมัน เริ่มจากการตรวจลายเซ็น PDF กับระดับ PAdES ด้วย PDFium Component เพื่ออ่าน dictionary กับระดับ baseline ดูทำไม validator ถึงปฏิเสธลายเซ็น PAdES สำหรับความล้มเหลวเชิงโครงสร้างที่มาก่อนคำถามเรื่อง revision ใด ๆ แล้วหลอมคำตัดสินเข้าไปในการ audit ความเสี่ยงความปลอดภัยของ PDF ที่กว้างขึ้น คู่กับการเช็ก JavaScript กับไฟล์ฝัง TPdf.AnalyzeSignatureRevisions, TPadesSignatureFieldOptions และตัวเขียน PAdES แบบขยายที่แสดงไว้ในบทความนี้มาพร้อมกับ PDFium Component สำหรับ Delphi, C++Builder และ Lazarus