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

DocMDP กับ FieldMDP: ตรวจสอบ Revision ของ PDF ใน Delphi

PDF ที่ลงลายเซ็นแล้วเปลี่ยนแปลงหลังจากลงลายเซ็นไม่ได้เสียหายเสมอไป ISO 32000-1 อนุญาตให้มีการอัปเดตแบบ incremental บนลายเซ็นได้ และมีเพียงบางส่วนเท่านั้นที่ละเมิดนโยบายที่ผู้ลงลายเซ็นตั้งไว้ HotPDF Component สำหรับ Delphi และ C++Builder ตอบคำถามนี้ด้วย AnalyzeLoadedSignatureRevisions ซึ่งจัดหมวดหมู่ทุก revision หลังลงลายเซ็นและให้คะแนนเทียบกับ DocMDP และ FieldMDP สถานการณ์นี้คุ้นเคยกับทุกคนที่ส่งมอบซอฟต์แวร์สัญญา ลูกค้าของคุณลงลายเซ็นในสัญญาซื้อขาย ส่งออกไป แล้วได้กลับมาพร้อมหน้าภาคผนวกที่แนบเพิ่ม ตัวอ่านแสดงแถบสีเหลืองบอกว่าลายเซ็นยังคงสมบูรณ์แต่เอกสารถูกเปลี่ยนแปลงหลังลงลายเซ็นแล้ว และไม่มีใครในห้องบอกได้ว่านั่นเป็นขั้นตอนการลงลายเซ็นร่วมตามปกติ หรือมีคนแอบแก้สัญญาที่ลงลายเซ็นแล้วอย่างเงียบ ๆ

อะไรคือการเปลี่ยนแปลงที่ถูกกฎหมายหลังลงลายเซ็น

การเปลี่ยนแปลงจะถูกกฎหมายเมื่อหมวดหมู่เชิงความหมายของมันอยู่ในสิทธิ์ที่ลายเซ็นแบบ certifying ประกาศไว้ ISO 32000-1 §12.8.2.2 กำหนด DocMDP transform ด้วยค่า /P เป็น 1, 2 หรือ 3 คือ 1 ไม่อนุญาตการเปลี่ยนแปลงใด ๆ เลย 2 อนุญาตการกรอกฟอร์มและการลงลายเซ็น 3 อนุญาตการกรอกฟอร์ม การลงลายเซ็น และ annotation HotPDF เปิดให้ใช้ค่าเหล่านี้เป็น THPDFDocMDPPermission คือ dmpNoChanges, dmpFormFillAndSign และ dmpFormFillSignAndAnnotate โดย dmpNone สงวนไว้สำหรับผลการตรวจสอบที่ไม่มี DocMDP transform เลย

หมวดหมู่เหล่านี้เรียงลำดับ และการเรียงลำดับนั้นคือกลไกของการตรวจสอบทั้งหมด THPDFRevisionModificationLevel รัน rmlNone, rmlLongTermValidation, rmlFormFillAndSign, rmlAnnotations, rmlOther เรียงลำดับอย่างตั้งใจให้ ordinal ที่มากกว่าไม่เคยหมายถึงข้อจำกัดที่น้อยกว่า เอกสารทั้งฉบับจะลดรูปเหลือระดับสูงสุดที่พบใน revision ทุกตัวหลังลายเซ็น และการเปรียบเทียบ DocMDP กลายเป็นการทดสอบจำนวนเต็มค่าเดียว มีข้อสังเกตหนึ่งที่สำคัญตั้งแต่ต้น ที่ dmpNoChanges การวิเคราะห์ยังคงยอมรับ rmlLongTermValidation การเพิ่มวัสดุตรวจสอบ DSS และ VRI หรือการประทับเวลาเอกสารลงในไฟล์ที่ certify แล้วคือการบำรุงรักษาลายเซ็น ไม่ใช่การแก้ไขเอกสาร และการถือว่ามันเป็นการละเมิดจะทำลายทุกเวิร์กโฟลว์การเก็บถาวรระยะยาวที่มีอยู่

HotPDF สร้าง revision chain ขึ้นใหม่ได้อย่างไร

ด้วยโครงสร้าง ไม่ใช่ heuristic ตาม ISO 32000-1 §7.5.6 การอัปเดตแบบ incremental จะต่อท้าย cross-reference section ใหม่ที่ /Prev ชี้ไปยังส่วนก่อนหน้า ดังนั้น HotPDF จึงอ่าน startxref จากท้ายไฟล์ parse ส่วนนั้น เดินตาม /Prev ย้อนกลับไปเรื่อย ๆ และคืนค่าส่วนต่าง ๆ เรียงจากเก่าสุดไปใหม่สุด มีขีดจำกัดความปลอดภัยสองอย่างในลูปนั้น ทั้งคู่ควรรู้ไว้เมื่อตรวจสอบไฟล์ที่ล้มเหลว /Prev ที่ชี้ไปยัง offset ที่เคยเยี่ยมชมแล้วจะยุติการเดินด้วยการวินิจฉัยแบบวงจรที่ชัดเจนแทนที่จะวนไม่รู้จบ และ chain ที่ยาวเกินหนึ่งพัน revision จะถูกปฏิเสธไปเลย ทั้งสองอย่างจะปรากฏใน Analysis.Issue โดยฟังก์ชันคืนค่า False และไม่ควรถูกปิดบังไว้ เพราะ /Prev แบบวงจรคือไฟล์ที่ผิดรูปแบบหรือเป็นภัย ไม่ใช่แค่ไฟล์ที่ผิดปกติ

รูปแบบทางประวัติศาสตร์สี่แบบพบได้ในเอกสารจริง และทั้งสี่แบบถูกจัดการ ตาราง xref แบบดั้งเดิมที่ parse ทีละบรรทัด cross-reference stream ที่คลายบีบอัดและถอดรหัสผ่านฟิลด์ /W และ /Index ไฟล์แบบ hybrid-reference ที่ trailer แบบดั้งเดิมพกคีย์ /XRefStm ที่จะถูก parse และรวมเข้ากับ revision เดียวกัน (กรณีของ Office producer ครอบคลุมใน บทความเรื่อง hybrid cross-reference stream) และ object ที่อาศัยอยู่ใน container ObjStm ซึ่งสำคัญเพราะการอัปเดตสมัยใหม่มักใส่ dictionary ที่เปลี่ยนไว้ใน stream ที่บีบอัดแทนที่จะเขียนตรง ๆ ตามที่กล่าวไว้ใน บทความเรื่อง object stream และการอัปเดตแบบ incremental ลายเซ็นคือจุดยึดของการแบ่ง /ByteRange[2] + /ByteRange[3] กลายเป็น SignedRevisionLength และทุกส่วนที่ offset นี้หรือมากกว่าคือหลังลายเซ็น ส่วนว่า byte range ยังคง hash ถูกต้องหรือไม่เป็นคำถามแยกต่างหาก ตอบโดย VerifyLoadedSignature และครอบคลุมใน บทความเรื่องการตรวจสอบลายเซ็นดิจิทัลของ PDF

วัตถุแต่ละชิ้นที่เปลี่ยนแปลงถูกจัดหมวดหมู่อย่างไร

การจัดหมวดหมู่รันต่อ object แล้วแพร่กระจายตาม reference สำหรับทุก object number ที่ section หลังลายเซ็นแตะ HotPDF จะอ่าน body ใหม่และ body ที่เคยอยู่ใน snapshot ตอนลงลายเซ็น body ที่เหมือนเดิมคือ rmlNone เพราะ producer มักเขียน object ใหม่โดยไม่เปลี่ยนแปลงจริง ๆ ตัวจดจำถูกออกแบบให้แคบโดยตั้งใจ object ประเภท /Type /DocTimeStamp หรือที่ /SubFilter เป็น ETSI.RFC3161 คือ rmlLongTermValidation เช่นเดียวกับสิ่งใดก็ตามที่เข้าถึงได้จาก catalog tree /DSS dictionary ประเภท /Type /Sig คือ rmlFormFillAndSign สำหรับ container การทดสอบคือคีย์ไหนที่เปลี่ยน ไม่ใช่ object นั้นคืออะไร catalog อาจได้รับหรือเปลี่ยนแค่ /DSS, /Extensions หรือ /AcroForm เท่านั้น AcroForm dictionary เปลี่ยนได้แค่ /Fields, /SigFlags, /NeedAppearances, /DR, /DA หรือ /Q หน้าเปลี่ยนได้แค่ /Annots ฟิลด์หรือ widget เปลี่ยนได้แค่ /V, /AP, /AS หรือ /M สิ่งใดที่อยู่นอกชุดเหล่านี้จะตกลงไปเป็น rmlOther ซึ่งเป็นวิธีที่หน้าภาคผนวกที่แนบเพิ่มถูกจับได้พอดี การเพิ่มหน้าจะจัดเรียง page tree ใหม่ในแบบที่ไม่มี whitelist ใดครอบคลุม และไม่มีการกรอกฟอร์มที่ถูกกฎหมายใดคล้ายกับมันเลย

จากนั้นระดับต่าง ๆ จะแพร่กระจาย โดยแต่ละ container สืบทอดระดับสูงสุดของ child ที่เปลี่ยนแปลงซึ่งมันชี้ไปหา วนซ้ำจนการกำหนดค่านิ่ง นี่คือสิ่งที่ทำให้ appearance stream ทำงานได้ text field ที่ถูกกรอกจะเขียน /V ใหม่และชี้ไปที่ stream /AP ใหม่ และ stream นั้นเองก็เป็นแค่ blob นิรนามของ content operator ที่ไม่มี type ให้จดจำ เพราะฟิลด์ที่เป็นเจ้าของมันคือ rmlFormFillAndSign stream นั้นจึงสืบทอดระดับเดียวกันแทนที่จะตกลงไปเป็น rmlOther การแพร่กระจายแบบเดียวกันนี้พา DSS context ไปยัง certificate และ revocation stream ที่มิเช่นนั้นจะจัดหมวดหมู่ไม่ได้

ทำไม object ที่อ่านไม่ได้จึงถือเป็นการละเมิด

เพราะทางเลือกอื่นคือตัวตรวจสอบที่ถูกเอาชนะด้วยการเขียนอะไรบางอย่างที่มันไม่เข้าใจ สามสถานการณ์ที่จบลงที่ rmlOther โดยไม่มีข้อยกเว้นใน HotPDF คือ object ที่ body ของมันไม่สามารถอ่านได้จาก revision, object ที่ revision ทำเครื่องหมายว่าว่าง (free) และ object ที่ไม่ตรงกับตัวจดจำใดข้างต้นเลย แต่ละกรณีจะบันทึกการวินิจฉัยเฉพาะไว้ในฟิลด์ Issue ของ revision ดังนั้นผู้ปฏิบัติงานจะเห็นได้ว่า object number ใดที่ทำให้ได้ผลลัพธ์นี้

การทำให้ว่างเป็นกรณีที่คมชัดที่สุดในสามกรณี revision หลังลายเซ็นที่ทำเครื่องหมายว่า object ที่เคยกำหนดไว้ก่อนหน้าเป็นว่าง หมายความว่าเนื้อหาถูกลบออกจากเอกสารที่ลงลายเซ็นแล้ว และไม่มีระดับสิทธิ์ใดภายใต้ §12.8.2.2 ที่อนุญาตสิ่งนี้ object number จะไปอยู่ใน FreedObjectNumbers และ revision จะถูกยกระดับเป็น rmlOther object ที่อ่านไม่ได้ตามตรรกะเดียวกันแต่ด้วยเหตุผลต่างกัน ตัวตรวจสอบที่ไม่สามารถ parse object ได้ไม่มีพื้นฐานที่จะเรียกมันว่าไม่เป็นภัย และการตอบสนองที่ซื่อสัตย์ต่อสิ่งนั้นไม่ใช่ความเงียบ การรายงานโครงสร้างที่ผิดปกติแต่ไม่เป็นภัยว่าเป็นการละเมิดจะเสียเวลาการตรวจสอบของมนุษย์ ความผิดพลาดในทางกลับกันคือการส่งมอบสัญญาที่ลงลายเซ็นแล้วโดยมีการแก้ไขที่ไม่มีใครสังเกตเห็นซ่อนอยู่ข้างใน

การอ่านผลตัดสินใน Delphi

การเรียกใช้งานสั้นมาก โหลดเอกสาร เลือก index ลายเซ็น อ่าน record ฟังก์ชันแบบไม่มีพารามิเตอร์จะเปิดไฟล์ที่เอกสารถูกโหลดมาจากใหม่ และแบบ TStream รับไบต์ที่ผู้เรียกจัดหาให้และคืนตำแหน่ง stream กลับก่อนคืนค่า PolicyCompliant คือค่า boolean ตัวเดียวที่ผู้เรียกส่วนใหญ่ต้องการ รวมสามการตัดสินใจอิสระเข้าด้วยกัน คือความถูกต้องเชิงโครงสร้างของ permission dictionary, DocMDPCompliant และ FieldMDPCompliant ควรแสดงส่วนประกอบเหล่านี้ให้เห็นใน UI ของคุณแทนที่จะยุบรวมมัน และสังเกตว่าเอกสารที่ไม่มี DocMDP transform จะปล่อยให้ DocMDPCompliant เป็น True เพราะลายเซ็น approval แบบธรรมดาไม่ได้ประกาศนโยบายใดที่จะละเมิดได้ และ ModificationLevel รวมจึงเป็นเชิงบรรยาย ไม่ใช่คำตัดสิน

var
  Pdf: THotPDF;
  Analysis: THPDFSignatureRevisionAnalysis;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-countersigned.pdf') > 0 then
    begin
      if Pdf.AnalyzeLoadedSignatureRevisions(0, Analysis) then
      begin
        if Analysis.PolicyCompliant then
          Writeln('Post-signature changes stay inside the signing policy')
        else
          Writeln('Policy violation: ', string(Analysis.Issue));
      end
      else
        Writeln('Analysis could not run: ', string(Analysis.Issue));
    end;
  finally
    Pdf.Free;
  end;
end;

สำหรับการตรวจสอบเบื้องต้น คุณมักต้องการรายละเอียดแบบต่อ revision มากกว่าสรุปรวม เพราะมันบอกได้ว่าสิ่งผิดพลาดเกิดขึ้นตอนไหนในประวัติเอกสาร แต่ละรายการใน Analysis.Revisions พก index ของมันใน chain, offset ของ cross-reference ที่มันถูกเขียนไว้, ระดับการเปลี่ยนแปลงของตัวมันเอง และ object number ที่เกี่ยวข้อง

const
  LevelNames: array[THPDFRevisionModificationLevel] of string =
    ('none', 'long-term validation', 'form fill and sign',
     'annotations', 'other');
var
  I: Integer;
begin
  Writeln(Format('%d revisions in chain, signature sits at index %d',
    [Analysis.TotalRevisionCount, Analysis.SignedRevisionIndex]));
  for I := 0 to High(Analysis.Revisions) do
    Writeln(Format('  rev %d at offset %d: %s (%d changed, %d freed) %s',
      [Analysis.Revisions[I].RevisionIndex,
       Analysis.Revisions[I].XRefOffset,
       LevelNames[Analysis.Revisions[I].ModificationLevel],
       Length(Analysis.Revisions[I].ChangedObjectNumbers),
       Length(Analysis.Revisions[I].FreedObjectNumbers),
       string(Analysis.Revisions[I].Issue)]));
end;

FieldMDP ถูกตัดสินแยกต่างหาก และนั่นคือความตั้งใจ

เอกสารสามารถผ่านเกณฑ์ DocMDP ได้และยังคงไม่ถูกกฎหมายอยู่ดี นี่คือเหตุผลที่ FieldMDPCompliant เป็น boolean แยกต่างหากแทนที่จะพับเข้าไปในการเปรียบเทียบระดับ ISO 32000-1 §12.8.2.4 กำหนด FieldMDP transform และ §12.7.5.5 กำหนด entry /SigFieldLock ที่เกี่ยวข้อง เพื่อล็อกฟิลด์ฟอร์มที่ระบุชื่อไว้ ณ ขณะลงลายเซ็น แม้ในกรณีที่เอกสารทั้งฉบับยังอนุญาตให้กรอกฟอร์มได้อยู่ การกรอกฟิลด์คือการกระทำระดับ 2 การกรอกฟิลด์ที่ผู้ลงลายเซ็นล็อกไว้คือการละเมิดไม่ว่าจะระดับใดก็ตาม HotPDF อ่านขอบเขตเข้าไปใน THPDFFieldLockAction เป็น flaAll, flaInclude หรือ flaExclude โดย flaNone สำหรับผลลัพธ์ที่ไม่มีนโยบายล็อก และชื่อเข้าไปใน Permissions.FieldNames flaAll ล็อกทุกอย่าง flaInclude ล็อกเฉพาะชื่อที่ระบุ flaExclude ล็อกทุกอย่างยกเว้นชื่อเหล่านั้น มีรายละเอียดหนึ่งที่สำคัญเมื่ออ่านผลลัพธ์ คือมีเพียงฟิลด์ที่มีอยู่แล้วใน snapshot ตอนลงลายเซ็นเท่านั้นที่ถูกรายงานใน ChangedFieldNames เพราะฟิลด์ที่ถูกสร้างขึ้นทั้งหมดหลังลงลายเซ็นไม่มีสถานะที่ลงลายเซ็นไว้ให้ขัดแย้งด้วย และจะถูกจับได้ผ่านเส้นทาง DocMDP แทน

var
  Source: TFileStream;
  Analysis: THPDFSignatureRevisionAnalysis;
  I: Integer;
begin
  Source := TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
  try
    if Pdf.AnalyzeLoadedSignatureRevisions(0, Source, Analysis) then
      if Analysis.Permissions.HasFieldMDP and (not Analysis.FieldMDPCompliant) then
        for I := 0 to High(Analysis.ChangedFieldNames) do
          Writeln('modified after locking: ',
            string(Analysis.ChangedFieldNames[I]));
  finally
    Source.Free;  // stream position was restored before the call returned
  end;
end;

สิ่งที่การวิเคราะห์นี้จะไม่บอกคุณ

มันไม่ตรวจสอบลายเซ็น AnalyzeLoadedSignatureRevisions ให้เหตุผลเกี่ยวกับโครงสร้างและสิทธิ์ ส่วนว่า byte range ที่ลงลายเซ็นยัง hash ตรงกับค่าใน CMS blob หรือไม่ และ certificate ของผู้ลงลายเซ็น chain ไปยังสิ่งที่คุณเชื่อถือหรือไม่ ตอบโดย VerifyLoadedSignature และ VerifyLoadedSignatureWithTrust ไฟล์อาจสอดคล้องตามนโยบายอย่างสมบูรณ์แต่ไร้ค่าทางการเข้ารหัสก็ได้ ดังนั้นการตรวจสอบทั้งสองแบบควรอยู่คู่กันในทุก acceptance gate จริง มันยังไม่อ่านเจตนาภายใน content stream หน้าที่ content stream ถูกแทนที่ทั้งหมดจะถูกจับว่าเป็นการเปลี่ยนแปลงนอก whitelist แต่การวิเคราะห์จะไม่บอกคุณว่าการแทนที่นั้นสลับตัวเลขการชำระเงิน ผลตัดสิน rmlOther หมายความว่ามนุษย์ควรตรวจสอบ ไม่ใช่ว่ามีการฉ้อโกงเกิดขึ้น และผลตัดสินที่สอดคล้องตามนโยบายหมายความว่าการเปลี่ยนแปลงเข้าข่ายหมวดหมู่ที่อนุญาต ไม่ใช่ว่าการเปลี่ยนแปลงนั้นเป็นที่ต้องการ เมื่อสิ่งที่คุณต้องการคือสิ่งที่ผู้ลงลายเซ็นประกาศไว้ โดยไม่ต้องเดินตาม revision GetLoadedSignaturePermissions จะคืน policy dictionary ให้เพียงลำพัง

ทุกสิ่งที่กล่าวถึงในบทความนี้ทำงานแบบเนทีฟใน Delphi และ C++Builder โดยไม่ต้องมีบริการลงลายเซ็นภายนอกเข้ามาเกี่ยวข้อง ซึ่งเป็นสิ่งที่ทำให้มันใช้งานได้จริงกับทุกเอกสารขาเข้า ไม่ใช่แค่เอกสารที่มีคนสงสัยไว้แล้ว API ลายเซ็นและ revision ฉบับเต็ม รวมถึงเมธอด permission และ verification ที่มันจับคู่ด้วย เป็นส่วนหนึ่งของ HotPDF Component สำหรับ Delphi และ C++Builder