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

ลงนาม PAdES ด้วย identity จาก Keychain ของ macOS ใน Delphi

PDFium VCL ลงนามเอกสาร PAdES ด้วย private key ที่เก็บอยู่ใน Keychain ของ macOS ผ่าน backend ที่ resolve สัญลักษณ์ของ Security กับ CoreFoundation ทุกตัวตอนรันไทม์ด้วย dlopen กับ dlsym ไม่มีอะไรถูกผูกไว้ตอนลิงก์เลย ชื่อสัญลักษณ์ที่พิมพ์ผิดจะปรากฏตัวเป็น KeychainAvailable คืนค่า False พร้อม KeychainMissingSymbols ที่ชี้ชื่อตัวปัญหา ไม่ใช่เป็น linker error หรือแครช

การเลือกนี้ถูกบังคับโดยข้อจำกัดที่ไม่ค่อยน่าพอใจ แต่วิธีจัดการกับมันเอาไปใช้ต่อได้ทั่วไป ยูนิตนี้ถูกเขียนบนเครื่องที่ไม่มี macOS SDK ชื่อสัญลักษณ์ framework ทุกตัวกับค่าคงที่ทุกตัวจึงมาจากเอกสาร และไม่มีตัวไหนได้เช็กกับ header เลย การตอบสนองที่ผิดต่อสถานการณ์แบบนี้คือเขียนโค้ดอย่างระมัดระวังแล้วสุ่มเสี่ยงดวง การตอบสนองที่ถูกคือจัดระบบให้ความผิดพลาดที่หลีกเลี่ยงไม่ได้ประกาศตัวเองในรูปแบบที่ตามหาเจอง่ายที่สุด

ทำไม dynamic binding จึงเป็นทางเลือกที่ถูกแม้บนแพลตฟอร์มเป้าหมาย

เพราะมันเปลี่ยนความล้มเหลวตระกูลที่หยุดโปรแกรมทั้งตัวให้กลายเป็นความล้มเหลวตระกูลที่รายงานตัวเอง การอ้างอิง framework แบบลิงก์สแตติกที่ผิดจะล้มตอนลิงก์บนเป้าหมายและไม่มีวันลิงก์ผ่านที่อื่น ส่วนแบบผูกไดนามิกที่ผิดจะให้ backend ที่ใช้ไม่ได้กับรายชื่อที่ resolve ไม่สำเร็จ รอบรันแรกบนเครื่อง Mac จึงเปลี่ยนคำถามจากทำไมมันใช้ไม่ได้ ให้เหลือเพียงบรรทัดเดียวที่ชี้ชื่อตัวพิมพ์ผิดมาให้เลย

มีประโยชน์ข้อที่สองที่คืนกำไรให้ทุกวัน ไม่ใช่ครั้งเดียว เพราะยูนิตไม่ลิงก์ framework ใดเลย มันคอมไพล์ได้ทุกแพลตฟอร์ม บิลด์ Windows ปกติจึงคอยเช็กไวยากรณ์ ไทป์ และ uses clause ของมันต่อเนื่อง ยูนิตที่คอมไพล์ได้เฉพาะบนแพลตฟอร์มที่ไม่มีใครในทีมถือครองคือยูนิตที่ไม่มีคอมไพเลอร์มองมันเลย และมันจะเสื่อมสภาพอย่างเงียบ ๆ ทุกครั้งที่มีการ refactor ไทป์ที่ใช้ร่วมกัน

uses
  FPdfCrypto, FPdfCryptoMac;

var
  Options: TPadesSignerOptions;
begin
  if not KeychainAvailable then
    raise Exception.Create('Keychain backend unavailable, unresolved: ' +
      KeychainMissingSymbols);

  ConfigureKeychainSignerProvider;   // ติดตั้งเป็น backend ผู้ลงนาม PAdES
  ConfigureKeychainCmsVerifier;      // และเป็น backend ฝั่งตรวจสอบด้วย

  Writeln('signer backend  : ', PadesCryptoBackendName);
  Writeln('verify backend  : ', PadesCmsVerificationBackendName);

  Options := TPadesSignerOptions.Default;
  Options.CertificateThumbprint := 'B1 3F 9C ...';   // SHA-1 พิมพ์เล็กใหญ่ก็ได้
  Options.PaddingScheme := psRsaPss;
end;

สัญลักษณ์ที่เอ็กซพอร์ตสองชนิด อ่านสองแบบ

นี่คือรายละเอียดที่สับสนที่สุดชิ้นเดียวใน binding ทั้งชุด เข้าใจสลับข้างเมื่อไรจะคอมไพล์ผ่านสวยงามแต่ไปล้มตอนรันไทม์ CoreFoundation กับ Security เอ็กซพอร์ตสิ่งที่ต่างกันคนละตระกูลผ่านการเรียก dlsym ตัวเดียวกัน โค้ดจึงต้องรู้ว่าอะไรคืออะไร

ค่าคงที่ที่มีชื่อ อย่าง key ของ keychain item class กับ boolean singleton ของ CoreFoundation เป็นตัวแปรที่เอ็กซพอร์ตมา โดยเนื้อในคือ CFStringRef หรือ CFBooleanRef ที่คุณต้องการ dlsym คืนที่อยู่ของตัวแปรนั้น คุณจึงต้อง dereference หนึ่งครั้งเพื่อได้ค่ามา ส่วนโครงสร้างตาราง callback อย่าง dictionary key กับ value callbacks เป็นโครงสร้างที่เอ็กซพอร์ตมา dlsym คืนที่อยู่ของโครงสร้าง ซึ่งเป๊ะกับพอยน์เตอร์ที่ฟังก์ชันสร้าง dictionary คาดหวัง ถ้าไป dereference ตัวนี้ คุณจะส่งเวิร์ดเครื่องแรกของโครงสร้างไปเป็นพอยน์เตอร์แทน

ความผิดพลาดทั้งสองแบบไม่ให้ compile error และไม่ให้ runtime error ที่อ่านรู้เรื่องด้วย สิ่งที่ได้คือพอยน์เตอร์ขยะที่ไปล้มตรงไหนสักแห่งปลายทาง วิธีทำให้การแยกสองกรณีนี้ผิดไม่ได้คือเลิกฝากความจำไว้กับหัวคน: ฟังก์ชัน helper สองตัว หนึ่งตัวผูกแล้ว dereference อีกตัวผูกแล้วไม่ dereference call site จึงประกาศเองว่าขอสัญลักษณ์ชนิดไหน และให้ helper บังคับส่วนที่เหลือ

แผนภาพ backend Keychain ของ macOS ใน PDFium VCL ที่ resolve สัญลักษณ์ของ Security กับ CoreFoundation ผ่าน dlsym: kSecClass เป็นตัวแปรที่เอ็กซพอร์ตซึ่ง BindConstant dereference หนึ่งครั้งเพื่อได้ค่า CFStringRef ขณะที่ kCFTypeDictionaryKeyCallBacks เป็นโครงสร้างที่เอ็กซพอร์ตซึ่ง BindStruct ส่งผ่านด้วยที่อยู่ และการสลับกฎสองข้อนี้จะให้พอยน์เตอร์ขยะไหลไปปลายทาง
การเรียก dlsym หนึ่งครั้งคืนสิ่งที่ต่างตระกูลกันสองอย่าง: ที่อยู่ของตัวแปรที่เก็บ CFTypeRef และที่อยู่ของโครงสร้าง callback ฟังก์ชัน helper สองตัวทำการตัดสิน dereference หรือไม่ที่จุดผูก แทนที่จะไปตัดสินกันตอนอยู่ในหน่วยความจำ
// ตัวแปรที่เอ็กซพอร์ต: dlsym ให้ที่อยู่ของตัวแปรที่เก็บ CFTypeRef
// จึงต้อง dereference หนึ่งครั้ง
FSecClassKey := BindConstant(SecurityLib, 'kSecClass');

// โครงสร้างที่เอ็กซพอร์ต: dlsym ให้ที่อยู่ของตัวโครงสร้างเอง ซึ่งเป็น
// สิ่งที่ API ต้องการ ห้าม dereference
FKeyCallbacks := BindStruct(CoreFoundationLib,
  'kCFTypeDictionaryKeyCallBacks');

ทำไมลายเซ็น RSA-PSS ถึงต้องมี fallback แยกสองชั้น?

เพราะอัลกอริทึมนี้หายไปได้สองทางที่เป็นอิสระต่อกัน และมีทางเดียวเท่านั้นที่เป็นคำถามเรื่องเวอร์ชัน ค่าคงที่อัลกอริทึม digest-signing ของ PSS โผล่มาใน macOS 10.13 บนระบบเก่ากว่านั้นสัญลักษณ์ก็แค่ไม่มีอยู่ binding จะได้ nil นั่นคือการเช็กเวอร์ชัน แยกอีกกรณี บนระบบที่ค่าคงที่มีอยู่ คีย์เฉพาะใบหนึ่งอาจยังปฏิเสธมัน framework ตอบคำถามนี้ผ่าน SecKeyIsAlgorithmSupported สำหรับคีย์ใบนั้น คีย์ที่ backed ด้วยฮาร์ดแวร์หรือคีย์ที่ติดแอตทริบิวต์จำกัดสิทธิ์สามารถปฏิเสธ PSS ได้ ขณะที่คีย์ซอฟต์แวร์บนเครื่องเดียวกันยอมรับมัน

ทั้งสองเส้นทางต้องไปจบที่ fallback เดียวกัน: สลับไปใช้ PKCS#1 v1.5 และจุดวิกฤตคือ fallback ต้องเปลี่ยน algorithm identifier ที่เขียนลงโครงสร้าง CMS ด้วย ไม่ใช่แค่การเรียกลงนาม การปล่อย algorithm identifier ของ PSS ออกไปทั้งที่จริงผลิตลายเซ็น v1.5 จะได้เอกสารที่ verifier ทุกตัวปฏิเสธพาดพิง ซึ่งแย่กว่าการรายงานว่า PSS ไม่รองรับอย่างเทียบกันไม่ได้ การลดระดับ (downgrade) ยอมรับได้ ส่วนความไม่ตรงกันระหว่างสิ่งที่คุณประกาศกับสิ่งที่คุณทำไม่ยอมรับได้ และนี่คือกฎทั่วไปของโค้ดลงนาม ไม่ใช่ความจุ้นจ้านเฉพาะ macOS รายละเอียดระดับลายเซ็นมีเรียงไว้ในบทความลงนาม PDF ด้วย PAdES B-B

ห่วงโซ่การตัดสินใจที่แสดงว่าทำไมการลงนาม RSA-PSS ใน backend Keychain ของ PDFium VCL ถึงต้องมี fallback อิสระสองชั้น: dlsym คืน nil สำหรับค่าคงที่ digest-signing บน macOS รุ่นก่อน 10.13, SecKeyIsAlgorithmSupported ปฏิเสธคีย์ที่ backed ด้วยฮาร์ดแวร์ได้ และประตูทั้งสองไหลรวมเข้าสู่การลดระดับ PKCS#1 v1.5 ชุดเดียวกันที่ CMS algorithm identifier ต้องเปลี่ยนตามไปด้วย
PSS ใช้ไม่ได้ได้ถึงสองชั้น หนึ่งชั้นต่อเวอร์ชัน macOS หนึ่งชั้นต่อคีย์ และมีเพียงประตูเวอร์ชันที่เป็นคำถามระดับระบบ ประตูทั้งสองไหลรวมสู่การลดระดับ v1.5 ชุดเดียวกัน และ CMS identifier ต้องตามไปด้วย

การเข้ารหัสลายเซ็น ECDSA และการกลับขั้วที่ควรจดไว้

เส้นทาง elliptic curve บน macOS ไม่ต้องแปลงอะไรเลย ซึ่งเป็นตรงข้ามกับสิ่งที่ binding แบบ PKCS#11 ต้องการ อัลกอริทึม digest-signing สำหรับ ECDSA ของ Security framework คืนลายเซ็นมาในรูป X9.62 DER แล้ว ซึ่งตรงกับสิ่งที่ CMS อยากได้พอดี ส่วน token PKCS#11 คืนคู่ P1363 แบบความกว้างคงที่ดิบ ๆ มาแทน ต้องเอาไปเข้ารหัสใหม่ก่อนจะยัดลงโครงสร้างลายเซ็น

backend สองตัวที่อิมพลีเมนต์อินเทอร์เฟซเดียวกันจึงต้องการการปฏิบัติตรงข้ามกันสำหรับอัลกอริทึมเดียวกัน และไม่มีฝ่ายใดผิด นี่คือชนิดความต่างพอดีที่ abstraction ต้องกลืนเอาไว้เอง ไม่ใช่ดีดออกไปให้เห็น: ชั้น PAdES สั่งให้ provider ลงนาม และธรรมเนียมการเข้ารหัสก็อยู่ข้างใน provider แค่นั้น ถ้ามันรั่วขึ้นไปข้างบน ผู้เรียกทุกตัวจะจบด้วยการแบก conditional ต่อ backend ติดไว้กับตัว รูปทรงเดียวกันนี้ปรากฏในเรื่องการลงนามระยะไกลที่เล่าไว้ในบทความ session ลงนาม PAdES ระยะไกลกับ HSM

เปรียบเทียบการเข้ารหัสลายเซ็น ECDSA ระหว่าง backend สองตัวของ PAdES signer ใน PDFium VCL: Security framework ของ Keychain บน macOS คืน X9.62 DER ที่ CMS รับได้ทันทีโดยไม่แปลงเลย ขณะที่ token PKCS#11 คืนคู่ P1363 แบบความกว้างคงที่ดิบ ๆ ที่ต้องเข้ารหัสใหม่ จึงเป็นหน้าที่ของ ResolvePadesSigner ที่รักษาธรรมเนียมการเข้ารหัสไว้ข้างใน provider
อินเทอร์เฟซ ECDSA ชุดเดิมต้องการการปฏิบัติตรงข้ามกันต่อ backend: Security ส่ง DER ที่เสร็จสมบูรณ์มาให้ ขณะที่ token PKCS#11 ส่ง P1363 ดิบ ๆ มาให้ การแปลงจึงอยู่ข้างใน provider และผู้เรียกไม่มีวันเห็น conditional ต่อ backend เลย
// อินเทอร์เฟซของ provider เหมือนกันทุกแพลตฟอร์ม การเลือกจึงเป็น
// การตัดสินใจตอนสตาร์ต ไม่ใช่รายการเรียก
{$IFDEF DARWIN}
  if KeychainAvailable then
    ConfigureKeychainSignerProvider;
{$ENDIF}
{$IFDEF MSWINDOWS}
  // ฝั่ง Windows ตัว provider ของ CNG ถูกติดตั้งโดยยูนิตแพลตฟอร์ม
{$ENDIF}

if not PadesCryptoAvailable then
  raise Exception.Create('no signing backend on this platform');

// จากจุดนี้ลงไป โค้ดลงนามเป็นกลางแพลตฟอร์ม
Signer := ResolvePadesSigner(Options);

กฎ reference counting ที่วางห่างกันแค่สามบรรทัด

การจัดการหน่วยความจำของ Core Foundation เดินตามธรรมเนียมการตั้งชื่อ และกับดักตรงนี้คือฟังก์ชันที่ใช้ธรรมเนียมต่างกันนั่งเรียงติดกันในบล็อกสั้น ๆ บล็อกเดียว ฟังก์ชันที่get certificate ออกจาก trust object คืน borrowed reference ที่ห้ามปล่อย ฟังก์ชันที่copy certificate ของผู้ลงนามหรือ copy ข้อมูลของมันคืน owned reference ที่ต้องปล่อย สามการเรียงต่อเนื่องกัน สองกฎความเป็นเจ้าของ และการปล่อยตัวที่ยืมมาไม่ได้ล้มตรงบรรทัดนั้น มันจะทำ retain count เสียหายแล้วไปดึงอะไรที่ไม่เกี่ยวข้องลงไปด้วยทีหลัง

วิธีกันภัยคืออ่านคำกริยาในชื่อฟังก์ชัน framework ทุกตัวก่อนเขียนโค้ดเก็บกวาด ทุกครั้ง ไม่มีข้อยกเว้น มันคือฉบับ Core Foundation ของการเช็กว่า API คืน copy หรือคืน view และราคาของการเข้าใจผิดคือแครชเป็นพักเป็น ๆ ไม่ใช่ error ที่ชัดเจน

สิ่งที่ backend ตัวนี้ไม่อวด

มันไม่เคยถูกรันบน macOS เลยตอนที่เขียนบทความนี้ และการพูดตรง ๆ แบบนี้มีประโยชน์กว่าคำรับรองแบบเหลือบ ๆ ให้เดาเอา สิ่งที่พิสูจน์ได้จริงแคบกว่าแต่ยังมีค่า: ยูนิตคอมไพล์ผ่านบน Windows เป็นส่วนหนึ่งของบิลด์รายวัน สัญลักษณ์ framework ทุกตัวถูกผูกด้วยชื่อตอนรันไทม์พร้อมระบุความล้มเหลวครบชุด และตรรกะเลือกอัลกอริทึมรวมถึง fallback ของ PSS ทั้งสองชั้นเป็น Pascal ธรรมดาที่อ่านทวนและใช้เหตุผลได้ รอบรันแรกบนเครื่อง Mac จะจบลงด้วยใช้งานได้ หรือไม่ก็ได้รายชื่อมาให้แก้

ฝาแฝดฝั่งตรวจสอบที่ใช้ตัวถอดรหัส CMS ระดับสูงกว่า แทนการประกอบโครงสร้าง CMS ด้วยมือ มีเล่าไว้ในบทความตรวจสอบลายเซ็น PDF บน macOS ด้วย SecTrust โดยใช้โครงสร้างพื้นฐานการผูกสัญลักษณ์ชุดเดียวกันและแนวทางวินิจฉัยแบบเดียวกัน

ไอเดียที่เอาไปใช้ต่อได้จริง ๆ อยู่ที่การวางความเสี่ยง ไม่ใช่เรื่อง macOS เมื่อคุณต้องเขียนโค้ดเทียบกับอินเทอร์เฟซที่ตรวจสอบไม่ได้ ให้เลือกโครงสร้างที่ความผิดพลาดตามหาเจอถูกที่สุด dynamic binding ที่มีรายชื่อ unresolved แบบชัดเจนเปลี่ยนสมมติฐานตรวจสอบไม่ได้ยี่สิบข้อให้เหลือบรรทัดวินิจฉัยเดียว backend ทั้งสองตัวมาพร้อมซอร์สกับPDFium Delphi component ถ้าชื่อสัญลักษณ์ตัวไหนต้องแก้จริง ๆ มันก็เป็นการแก้บรรทัดเดียวในซอร์สของคุณเอง ไม่ใช่การเปิดตั๋วขอซัพพอร์ต