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

Backend ประทับเวลาด้วย libcurl สำหรับ PDFium VCL บน FPC

PDFium VCL ส่งคำขอประทับเวลา RFC 3161 ผ่าน libcurl บนเป้าหมายที่ไม่ใช่ Windows โดยผูกแบบไดนามิกกับสัญลักษณ์แปดตัว เลียนรูปทรงเดียวกับ backend ฝั่ง Windows ที่ผูกเข้ากับ WinHTTP การตั้งค่าออปชันสองตัวตัดสินว่าการขนส่งจะเชื่อถือได้ใต้โหลดหรือไม่ และยูนิตทั้งตัวถูกตรวจสอบบนเครื่องที่คอมไพล์มันไปยังแพลตฟอร์มเป้าหมายไม่ได้เสียอีก

การประทับเวลาคือสิ่งที่เปลี่ยนลายเซ็นให้กลายเป็นสิ่งที่รอดจากการหมดอายุของใบรับรอง และมันคือปฏิบัติการเครือข่ายที่นั่งอยู่ข้างในปฏิบัติการลงนาม การผสมกันแบบนี้ทำให้การเลือกตัวขนส่งมีน้ำหนักกว่าที่มันเคยเป็นตามปกติ: มันรันบนเธรด worker คุยกับเซิร์ฟเวอร์ที่คุณไม่ได้ควบคุม และการค้างตรงนั้นจะทำให้ไพป์ไลน์ลงนามติดขัด ไม่ใช่แค่การโหลดหน้าเว็บ

ทำไมใช้ libcurl แทน HTTP client ของ FPC?

เพราะทางเลือกอื่นจะลาก TLS stack เข้ามาในรีโป แล้วเปลี่ยนงานของคุณให้กลายเป็นการดูแลการตรวจจับเวอร์ชันของมันต่อไปเรื่อย ๆ เส้นทางที่คิดออกง่ายที่สุดบน Free Pascal คือ fphttpclient คู่กับเลเยอร์ socket ของ OpenSSL และมันก็ล้มตรงรายละเอียด: บิลด์ OpenSSL binding ของ FPC 3.2.2 ตรวจจับ OpenSSL 3.x ได้ไม่น่าเชื่อถือบนดิสทริบิวชันสมัยใหม่ส่วนใหญ่ และ macOS ยังบวกความต่างของ LibreSSL ซ้อนเข้ามาอีก งานที่เริ่มจากการเรียก HTTP เล็ก ๆ ตัวหนึ่งกลายเป็นการดูแล TLS ABI ของคนอื่นไปตลอด

libcurl เลือก TLS backend ของตัวเองเองและตรวจห่วงโซ่ใบรับรองเทียบกับ trust store ของแพลตฟอร์ม ฝั่ง Pascal จึงไม่ต้องทำอะไรพวกนั้นเลย ชั้น binding มีแค่สัญลักษณ์แปดตัว ตัวเลขนี้แหละคือเหตุผล: พื้นที่สัมผัสที่เล็กลงระหว่างโค้ดของคุณกับดีเพนเดนซีที่ขยับตัวตลอด แปลว่าจุดให้การอัปเกรดดิสทริบิวชันทำคุณพังน้อยลง และมันตรงกับ backend ฝั่ง Windows ที่มีอยู่แล้ว ซึ่งผูกจุดเข้า WinHTTP กี่ตัวแบบเดียวกันพอดี

uses
  FPdfTsaFpc;

var
  ReqDer, RespDer: TBytes;
begin
  if not TsaHttpAvailable then
    raise Exception.Create('no HTTP transport for timestamping');

  Writeln('TSA transport: ', TsaHttpBackendName);

  ReqDer := BuildTimeStampQuery(DocumentDigest);
  if PostTimeStampQuery('https://tsa.example.org/tsr', ReqDer, RespDer) then
    AttachTimeStampToken(RespDer)
  else
    raise Exception.Create('timestamp request failed');
end;

ประกาศฟังก์ชัน variadic ของ C ใน Pascal

curl_easy_setopt กับ curl_easy_getinfo เป็นฟังก์ชัน variadic ฝั่ง C ซึ่ง Object Pascal ไม่มีทางแสดงออกได้ตรง ๆ ทางที่ใช้ได้จริงคือประกาศโปรโตไทป์คงที่หลายแบบ หนึ่งแบบต่อกลุ่มอาร์กิวเมนต์ ทุกแบบชี้ไปที่สัญลักษณ์เอ็กซพอร์ตตัวเดียวกัน: แบบรับ long, แบบรับพอยน์เตอร์ และอื่น ๆ เลือกใช้ที่ call site ตามสิ่งที่คุณส่งเข้าไปจริง

มันปลอดภัยด้วยเหตุผลเฉพาะตัวที่ควรเข้าใจ ไม่ใช่แค่ลอกกันมา อาร์กิวเมนต์แต่ละไทป์ในกลุ่มนี้ถูกส่งผ่านเรจิสเตอร์จำนวนเต็มภายใต้ calling convention ของแพลตฟอร์มที่กำลังใช้งาน ซึ่งก็คือจุดเดียวกับที่อิมพลีเมนเทชัน C ของ va_arg ไปอ่านค่า ลูกเล่นนี้จึงใช้ได้กับจำนวนเต็ม พอยน์เตอร์ และ handle แต่ไม่ใช้ได้กับอาร์กิวเมนต์จุดลอย ที่เดินทางผ่านเรจิสเตอร์อีกชุดหนึ่ง อย่าไปเพิ่มแบบรับ double ด้วยสมมติฐานว่ารูปแบบนี้ใช้ได้กับทุกกรณี

// สัญลักษณ์เอ็กซพอร์ตตัวเดียว แต่มีโปรโตไทป์คงที่หลายแบบ ทุกแบบส่ง
// อาร์กิวเมนต์ผ่านเรจิสเตอร์จำนวนเต็ม ซึ่งเป็นจุดที่ฝั่ง C ไปอ่านค่า
// แบบรับจุดลอยจะใช้ไม่ได้ และห้ามเพิ่มเด็ดขาด
type
  TCurlSetOptLong = function(Handle: Pointer; Option: Integer;
    Value: NativeInt): Integer; cdecl;
  TCurlSetOptPtr  = function(Handle: Pointer; Option: Integer;
    Value: Pointer): Integer; cdecl;

var
  curl_easy_setopt_long: TCurlSetOptLong;
  curl_easy_setopt_ptr:  TCurlSetOptPtr;

การตั้งค่าสองตัวที่ตัดสินว่าคำขอจะสำเร็จหรือไม่

ตัวแรกคือเฮดเดอร์ Expect: ว่าง ๆ ที่ส่งไปแบบชัดเจน libcurl เปิดการจับมือ HTTP 100-continue กับ request body ที่หนักเกินประมาณหนึ่งกิโลไบต์ และคำขอประทับเวลาที่แนบคำขอใบรับรองมาด้วยมักข้ามเกณฑ์นี้อยู่แล้ว เซิร์ฟเวอร์ TSA บางตัวไม่เคยตอบขั้นตอนต่อเนื่องเลย ไคลเอนต์จึงเผา timeout เต็มครู่ก่อนจะส่ง body ที่เซิร์ฟเวอร์จะยอมรับทันทีอยู่ดี การส่งเฮดเดอร์ Expect: ว่างจะกดการจับมือนี้ทิ้ง คำขอจึงผ่านในรอบเดียว

ตัวที่สองคือ CURLOPT_NOSIGNAL ซึ่งต้องตั้ง ถ้าไม่ตั้ง libcurl จะอิมพลีเมนต์ timeout การ resolve ชื่อด้วย SIGALRM กลไกที่ไม่ thread-safe การลงนามรันบนเธรด worker พฤติกรรมค่าเริ่มต้นจึงเป็นแครชแอบแฝงที่โผล่ตอนรันพร้อมกันหลายเธรด และไม่เคยโผล่ในเทสต์เธรดเดียว การเปิดแฟล็กนี้ปิดเส้นทางที่อาศัยสัญญาณทิ้ง แลกมาแค่ความละเอียดของ resolver timeout เท่านั้น

ทั้งสอง defect มีโปรไฟล์ร่วมกันที่ทำให้ตามจับทีหลังแพงมาก ไม่มีตัวไหนโผล่ในเทสต์เชิงฟังก์ชันกับเซิร์ฟเวอร์ที่ทำตัวดีบนเธรดเดียว ทั้งคู่ปรากฏตัวใน production กับ TSA เฉพาะตัวหนึ่ง ใต้โหลดจริง เวลาคุณผูกไลบรารีเครือข่าย ให้อ่านดูก่อนว่าค่าเริ่มต้นของมันสมมติอะไรเกี่ยวกับโปรเซสของคุณ ก่อนที่คุณจะสมมติเองว่ามันตรงกัน

แผนภาพการขนส่งประทับเวลาผ่าน libcurl ของ PDFium VCL แสดง curl_easy_setopt ที่ประกาศเป็นโปรโตไทป์ Pascal แบบ long กับพอยน์เตอร์คงที่ซึ่งส่งอาร์กิวเมนต์ผ่านเรจิสเตอร์จำนวนเต็ม, เฮดเดอร์ Expect ว่างที่กดการจับมือ HTTP 100-continue ทิ้ง, CURLOPT_NOSIGNAL ที่ตัดเส้นทาง SIGALRM ออกจากเธรด worker และเพดานขนาด response ฝั่งการขนส่ง
การตั้งค่าสองตัวตัดสินว่าคำขอจะสำเร็จหรือไม่: เฮดเดอร์ Expect ว่างช่วยเลี่ยงเซิร์ฟเวอร์ที่ไม่เคยตอบขั้นตอนต่อเนื่อง และ NOSIGNAL กัน timeout ตอน resolve ชื่อออกจากเส้นทางสัญญาณ ขณะที่การลงนามรันบนเธรด worker

จะตรวจสอบโค้ดที่คอมไพเลอร์ของคุณไม่มีวันได้เห็นอย่างไร?

ด้วยการบังคับให้คอมไพเลอร์ได้เห็นมันอยู่ดี ผ่านสำเนาที่ควบคุมไว้ เครื่องพัฒนาที่ใช้ที่นี่ไม่มี cross-compiler สำหรับ Linux หรือ macOS สาขา non-Windows ของยูนิตประทับเวลาจึงไม่เคยถึง code generator ในบิลด์ปกติเลย โค้ดที่ไม่เคยถูกคอมไพล์คือโค้ดที่เน่าเปื่อยแบบเงียบ ๆ: มีการเปลี่ยนชื่อไทป์ที่ใช้ร่วมกัน รายการพารามิเตอร์เปลี่ยน เพิ่มดีเพนเดนซียูนิต แล้วใคร ๆ ก็ไม่ทันสังเกตไปหลายเดือน

เทคนิคนี้ทำได้ด้วยมือล้วน ๆ คัดลอกยูนิตไปไดเรกทอรีชั่วคราว เปลี่ยนชื่อมัน แล้วแทนที่เงื่อนไข Windows ทุกตัว ทั้งรูป {$IFDEF MSWINDOWS} และรูป {$IF DEFINED(MSWINDOWS) ด้วยสัญลักษณ์ที่ไม่มีวันถูกนิยาม จากนั้นคอมไพล์สำเนาตัวนั้น เมื่อทั้ง 3,828 บรรทัดคอมไพล์ผ่าน คุณพิสูจน์ได้แล้วว่าเส้นทาง non-Windows ใช้ยูนิตที่มีอยู่จริง เรียกฟังก์ชัน backend ด้วยซิกเนเจอร์ที่ตรงกัน และอ้างอิงไทป์ที่อยู่ในขอบเขต นั่นไม่ใช่พิสูจน์ว่าการขนส่งใช้งานได้ และไม่มีอะไรให้สิ่งนั้นได้นอกจากแพลตฟอร์มเป้าหมายจริง แต่มันพิสูจน์ว่าสาขานี้ยังไม่พังอยู่ก่อน ซึ่งเป็นโหมดความล้มเหลวที่สะสมตัวขึ้นจริง ๆ

นิสัยคู่กันคือปล่อยตัวยูนิต libcurl ให้ปราศจาก platform guard มันจึงร่วมอยู่ในบิลด์ Windows ปกติแม้ไม่มีอะไรที่นั่นอ้างอิงมัน บิลด์รายวันจะได้เฝ้าไวยากรณ์กับไทป์ของมันให้ฟรี ๆ ยูนิตที่คอมไพล์ได้เฉพาะบนแพลตฟอร์มที่คุณไม่มีคือยูนิตที่ไม่มีคอมไพเลอร์ตรวจมันเลยแม้แต่ตัวเดียว และเหตุผลแบบเดียวกันนี้ใช้กับงานข้ามคอมไพเลอร์ทั้งชุดที่เล่าไว้ในบทความกับดักข้ามคอมไพเลอร์ของ Delphi และ FPC

จำกัดขอบเขตของสิ่งที่ไหลกลับมา

response ของการประทับเวลาคือโครงสร้าง DER ขนาดเล็ก และไม่มีอะไรในตัวการขนส่งบังคับเรื่องนี้ให้ เซิร์ฟเวอร์ที่ถูกบุกรุก ตั้งค่าผิด หรือถูกชี้ไป URL ผิด ส่งสตรีมอะไรกลับมาก็ได้ และไคลเอนต์ที่อ่านจนกว่าการเชื่อมต่อจะปิดจะกอดสะสมมันไว้อย่างเต็มใจ การขนส่งทั้งสองแบบจึงวางเพดานไว้ที่ response ซึ่งเป็นตำแหน่งที่ถูกต้องของขีดจำกัด: การปฏิเสธที่ชั้นขนส่งทำให้ body ที่ใหญ่เกินไม่มีวันถูกจัดสรรขึ้นมาเลย ขณะที่การเช็กระดับ parser จะทำงานเมื่อหน่วยความจำถูกผูกมัดไปแล้วเท่านั้น

เหตุผลเดียวกันใช้กับ URL backend รับเฉพาะ scheme ที่มันพูดได้อย่างมีความหมาย ความผิดพลาดจากการตั้งค่าจึงล้มทันทีพร้อมข้อความที่ชัดเจน แทนที่จะส่งต่อให้ libcurl ตีความไปตามที่การรองรับโปรโตคอลของมันอนุญาต

ตัวขนส่งอยู่ตรงไหนในภาพใหญ่ของการลงนาม

การประทับเวลาเป็นเพียงก้าวแรกของเรื่อง long-term validation ไม่ใช่ทั้งเรื่อง token ต้องถูกแนบเข้ากับลายเซ็น วัสดุตรวจสอบต้องถูกบันทึกลง document security store และ archive timestamp ต้องถูกต่ออายุก่อนที่ตัวปัจจุบันจะอ่อนแรงลง ส่วนเส้นทางทั้งหมดนี้มีเล่าไว้ในบทความลายเซ็น PDF ระยะยาวกับ RFC 3161 timestamps และ DSS

แผนภาพของ PDFium VCL ที่คำขอประทับเวลา RFC 3161 ไหลจาก DocumentDigest ผ่าน BuildTimeStampQuery และ PostTimeStampQuery ขึ้น libcurl ไปยังเซิร์ฟเวอร์ TSA, response DER ถูกวางเพดานที่ชั้นขนส่ง จากนั้น AttachTimeStampToken ป้อนเข้า DSS และการต่ออายุ archive timestamp ใน long-term validation
การประทับเวลาเป็นก้าวแรกของเรื่อง long-term validation: token ต้องถูกแนบ วัสดุตรวจสอบต้องบันทึกลง document security store และ archive timestamp ต้องต่ออายุก่อนตัวปัจจุบันอ่อนแรงลง

ตัวขนส่งยังเป็นหนึ่งชิ้นของแนวรับประเด็นความพกพาที่กว้างกว่านั้น: native library loader ที่เล่าไว้ในบทความโหลดไลบรารีเนทีฟบนเป้าหมายใดก็ได้ จัดการปัญหาตระกูลเดียวกันให้ตัวไบนารี PDFium ทั้งสองกรณีรูปแบบเหมือนกันเป๊ะ ผูกสัญลักษณ์จำนวนน้อยแบบไดนามิก รายงานให้ชัดว่าตัวไหนผูกไม่สำเร็จ และไม่ปล่อยให้ดีเพนเดนซีที่หายไปกลายเป็นความล้มเหลวตอนลิงก์ที่ทำให้แอปเริ่มทำงานไม่ได้

backend ประทับเวลาทั้งฝั่ง Windows และ non-Windows มาพร้อมกับPDFium Delphi component เลือกตามเป้าหมาย ไม่ใช่ตามการตั้งค่า แอป Lazarus บน Linux กับแอป Delphi บน Windows จึงผลิตลายเซ็นพร้อมประทับเวลาแบบเดียวกันผ่านเส้นทางภายในคนละแบบ