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 เฉพาะตัวหนึ่ง ใต้โหลดจริง เวลาคุณผูกไลบรารีเครือข่าย ให้อ่านดูก่อนว่าค่าเริ่มต้นของมันสมมติอะไรเกี่ยวกับโปรเซสของคุณ ก่อนที่คุณจะสมมติเองว่ามันตรงกัน
จะตรวจสอบโค้ดที่คอมไพเลอร์ของคุณไม่มีวันได้เห็นอย่างไร?
ด้วยการบังคับให้คอมไพเลอร์ได้เห็นมันอยู่ดี ผ่านสำเนาที่ควบคุมไว้ เครื่องพัฒนาที่ใช้ที่นี่ไม่มี 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
ตัวขนส่งยังเป็นหนึ่งชิ้นของแนวรับประเด็นความพกพาที่กว้างกว่านั้น: native library loader ที่เล่าไว้ในบทความโหลดไลบรารีเนทีฟบนเป้าหมายใดก็ได้ จัดการปัญหาตระกูลเดียวกันให้ตัวไบนารี PDFium ทั้งสองกรณีรูปแบบเหมือนกันเป๊ะ ผูกสัญลักษณ์จำนวนน้อยแบบไดนามิก รายงานให้ชัดว่าตัวไหนผูกไม่สำเร็จ และไม่ปล่อยให้ดีเพนเดนซีที่หายไปกลายเป็นความล้มเหลวตอนลิงก์ที่ทำให้แอปเริ่มทำงานไม่ได้
backend ประทับเวลาทั้งฝั่ง Windows และ non-Windows มาพร้อมกับPDFium Delphi component เลือกตามเป้าหมาย ไม่ใช่ตามการตั้งค่า แอป Lazarus บน Linux กับแอป Delphi บน Windows จึงผลิตลายเซ็นพร้อมประทับเวลาแบบเดียวกันผ่านเส้นทางภายในคนละแบบ