- นักพัฒนาคนหนึ่งได้แบ่งปันเส้นทางทั้งด้านเทคนิคและด้านจิตใจจากการลงมือสร้าง คอมไพเลอร์ ASN.1 (dasn1) ด้วยภาษา D ด้วยตนเอง
- โปรเจ็กต์นี้มีเป้าหมายเพื่อ รองรับใบรับรอง x.509 และการใช้งาน TLS 1.3 โดยต้องจัดการกับ การเข้ารหัส DER ที่ซับซ้อนของ ASN.1
- บทความอธิบายอย่างละเอียดถึงความเข้าใจยากของโครงสร้าง ASN.1, ความยากในการทำตามสเปก x.680~x.683, และวิธีใช้เมตาโปรแกรมมิงของภาษา D
- มีการอธิบายอย่างเป็นรูปธรรมว่าความสามารถของ D อย่าง static import, mixin template, typeof(), alias this ช่วยงานออกแบบการสร้างโค้ดและ AST/IR ได้อย่างไร
- ผู้เขียนสรุปว่า “ASN.1 นั้นทรมาน แต่ก็เป็นประสบการณ์ที่ได้เรียนรู้อย่างมาก” พร้อมถ่ายทอด ความยากในโลกจริงและความคุ้มค่าของการสร้างคอมไพเลอร์ อย่างตรงไปตรงมา
ภาพรวมและแรงจูงใจของโปรเจ็กต์
- ผู้เขียนกำลังพัฒนาเฟรมเวิร์ก asynchronous I/O บน D ชื่อ Juptune และเพื่อทำ TLS จึงจำเป็นต้องจัดการการเข้ารหัส ASN.1 DER ด้วยตนเอง
- การพาร์สโครงสร้างใบรับรอง x.509 ของ TLS จำเป็นต้องเข้าใจวิธีแทนข้อมูลที่ซับซ้อนของ ASN.1
- โปรเจ็กต์นี้เริ่มจากความท้าทายส่วนตัวเพื่อ การเรียนรู้และความสนุก และได้ดำเนินไปถึงขั้นที่สามารถพาร์สใบรับรองบางรายการได้สำเร็จจริง
- ASN.1 เป็นมาตรฐานเก่าตั้งแต่ยุค 1990 แต่ยังคง ถูกใช้อย่างแพร่หลายในระบบสมัยใหม่ เช่น TLS, SNMP, LDAP
- ผู้เขียนกล่าวว่า “ASN.1 ถูกใช้แพร่หลายในโลก แต่ผู้พัฒนาส่วนใหญ่แทบไม่รู้ด้วยซ้ำว่ามันมีอยู่”
ASN.1 คืออะไร
- ASN.1 (Abstract Syntax Notation One) คือ ภาษาสำหรับนิยามโครงสร้างข้อมูลและการเข้ารหัส เป็นเหมือน “บรรพบุรุษของ Protocol Buffers”
- มาตรฐานประกอบด้วย รูปแบบไวยากรณ์ (x.680~x.683) และ กฎการเข้ารหัส (BER, CER, DER, PER, XER, JER เป็นต้น)
- BER: รูปแบบ TLV พื้นฐาน รองรับความยาวแบบไม่สิ้นสุด
- CER: รูปแบบจำกัดของ BER และใช้ความยาวแบบไม่สิ้นสุดเสมอ
- DER: ส่วนย่อยแบบกำหนดแน่นอนของ BER, ใช้เป็นมาตรฐานในงานเข้ารหัสลับ
- PER/OER: การเข้ารหัสแบบบีบอัดในระดับบิต
- XER/JER: การเข้ารหัสบนพื้นฐาน XML·JSON
- แม้จะซับซ้อนเพราะมีรูปแบบการเข้ารหัสหลายชนิด แต่ก็มี ความยืดหยุ่นและการขยายต่อได้สูง
ความซับซ้อนของไวยากรณ์ ASN.1
- มาตรฐานหลักของ ASN.1 คือ x.680 ส่วนสเปกขยาย (x.681~x.683) เขียนด้วย สำนวนเชิงวิชาการที่เข้าใจยากมาก
- แม้จะสามารถพัฒนาได้ด้วย x.680 เพียงอย่างเดียว แต่ก็ยังยากมากเพราะมี กฎการแปลงความหมายและการแปลงรูปไวยากรณ์ จำนวนมาก
- x.681 นิยาม ระบบ Information Object Class และรองรับไวยากรณ์เริ่มต้นแบบเฉพาะของตนเอง
- ตัวอย่าง:
CALLED &name [WHO IS &age YEARS OLD]
- x.682 นิยาม Table Constraint และ x.683 นิยามชนิดแบบ Parameterized
- เป็นแนวคิดคล้าย generic ของภาษา D โดยสามารถรับทั้งชนิดและค่าเป็นพารามิเตอร์ได้
ความสามารถที่น่าสนใจของ ASN.1
- ระบบ Constraint: สามารถระบุช่วงค่าหรือขนาดของชนิดได้โดยตรงตอนนิยาม
- ตัวอย่าง:
UInt8 ::= INTEGER (0..255)
- รองรับตัวดำเนินการ
SIZE, UNION(|), INTERSECTION(^)
- ระบบจัดการเวอร์ชัน: ใช้
OBJECT IDENTIFIER เพื่อแยกเวอร์ชันของโมดูลได้อย่างชัดเจน
- ตัวอย่าง:
id-pkix1-implicit(19) vs id-mod-pkix1-implicit-02(59)
- ทำให้ระบุโมดูลได้อย่างชัดเจนโดยไม่เกิดการชนกันของชื่อ
เหตุใดภาษา D จึงเหมาะกับการสร้างโค้ด
- static import ของ D ช่วยป้องกันการชนกันของชื่อ และทำให้สามารถคงชื่อชนิดของ ASN.1 ไว้ได้ตามเดิม
- ความสามารถ module-local lookup (.Type1) ทำให้จำกัดขอบเขตการค้นหาสัญลักษณ์ได้อย่างชัดเจน
- ใช้ typeof() เพื่ออนุมานชนิดอัตโนมัติ จึงไม่ต้องจัดการเองด้วยมือในขั้นสร้างโค้ด
- การอนุญาต trailing comma ช่วยให้การสร้างโค้ดง่ายขึ้น
- ด้วย การต่อค่าคงที่ตอนคอมไพล์ จึงสามารถประกอบสตริงได้แม้ภายในฟังก์ชัน
@nogc
ตัวอย่างการใช้งานความสามารถของภาษา D ในการพัฒนา
AST node บนพื้นฐาน mixin template
- ใช้ความสามารถ mixin template ของ D เพื่อกำหนดโหนดของต้นไม้ไวยากรณ์ ASN.1 (AST)
- นำชนิดโหนดแต่ละแบบ (
List, Container, OneOf) กลับมาใช้ซ้ำในรูปเทมเพลต
- ลดความซับซ้อนด้วย การคัดลอกโค้ดในช่วงคอมไพล์ แทนการสืบทอดที่ยุ่งยาก
API แบบเทมเพลตและการตรวจสอบตอนคอมไพล์
- โหนด
Container สามารถมีโหนดย่อยหลายตัว และทำ การตรวจสอบชนิดตอนคอมไพล์
- เข้าถึงได้อย่างปลอดภัยในรูปแบบ
node.getNode!Asn1TagDefaultNode
- โหนด
OneOf ใช้เก็บค่าจากหลายชนิดได้หนึ่งชนิด และรองรับ pattern matching ผ่านฟังก์ชัน match
- เพราะต้องนิยามตัวจัดการสำหรับทุกชนิด จึงได้ ความปลอดภัยในระดับคอมไพล์
การใช้แพ็กเกจทดลองด้านการจัดการหน่วยความจำของ D
- ใช้
std.experimental.allocator เพื่อทำ การสร้าง/คืนหน่วยความจำของอ็อบเจ็กต์ในสภาพแวดล้อม @nogc
- สร้าง allocator แบบกำหนดเองด้วยการผสาน
Region, StatsCollector เป็นต้น
- แต่แพ็กเกจนี้ก็ยังคงอยู่ในสถานะ experimental มานานถึง 10 ปี
ความสามารถ alias this
- ใช้
alias this เพื่อทำให้ struct wrapper ทำงานเสมือนเป็นฟิลด์ภายในโดยตรง
- ตัวอย่าง: สามารถแคสต์อย่างกระชับในรูป
cast(Asn1ValueReferenceIr)item
version(unittest)
- ใช้คีย์เวิร์ด
version(unittest) เพื่อกำหนด ฟังก์ชันสำหรับการทดสอบโดยเฉพาะ ซึ่งจะไม่ถูกนำไปรวมในบิลด์จริง
test harness ด้วย template + with()
- ทำเทมเพลตให้กับ logic การทดสอบร่วม และใช้คำสั่ง
with() เพื่อ เขียนโค้ดทดสอบให้กระชับ
- สามารถเรียก
T() ได้แทน Harness.T()
ปัญหาหลักที่พบระหว่างการพัฒนา
Value Sequence Syntax
- ไวยากรณ์ของค่าหลายรูปแบบที่เริ่มด้วย
{} มี ความกำกวมตามบริบท
- ซับซ้อนถึงขั้นมีคอมเมนต์ใน parser ว่า “นี่ไม่สนุกเลย”
- เพราะแยกการวิเคราะห์ไวยากรณ์ออกจากการวิเคราะห์ความหมาย จึงทำให้ยิ่งจัดการยากขึ้น
ความไม่ชัดเจนของสเปก
- มีพฤติกรรมบางอย่างที่ เอกสารไม่ได้ระบุไว้อย่างชัดเจน เช่น กฎที่ระบุว่าในบางเงื่อนไขแท็กควรถูกจัดการเป็น
EXPLICIT
- วิธีจัดการเวอร์ชันของโมดูลเองก็ไม่ได้ถูกกำหนดไว้อย่างชัดเจน
ความจำเป็นต้องทำ constraint ซ้ำ 3 ชั้น
- สำหรับตรวจสอบไวยากรณ์
- สำหรับตรวจสอบความถูกต้องของค่า
- สำหรับสร้างโค้ดที่รันไทม์
- เมื่อต้องจัดการ UNION และ INTERSECTION ก็ทำให้ การประกอบข้อความ error ซับซ้อนขึ้นด้วย
ภาพฝันเรื่อง IR node ที่ไม่เปลี่ยนแปลง
- เดิมคิดว่าเมื่อแปลง AST ไปเป็น IR แล้วจะไม่ต้องแก้ไขอีก
แต่ในกระบวนการแปลงความหมาย เช่น AUTOMATIC TAGS ยังจำเป็นต้องเปลี่ยนข้อมูลอยู่
ความซับซ้อนรอบด้านของ ASN.1
- x.509 ใช้เพียงไวยากรณ์แบบเก่าจึงค่อนข้างง่าย แต่สเปกสมัยใหม่ จำเป็นต้องรองรับ x.681~x.683
- เพราะเหตุนี้ ASN.1 จึงแทบไม่ถูกใช้นอกเหนือจากวงการวิชาการและเชิงพาณิชย์บางส่วน
ปัญหา ANY DEFINED BY
ANY DEFINED BY เป็นโครงสร้างที่ชนิดจะเปลี่ยนไปตามค่าของฟิลด์อื่น
- dasn1 ไม่ได้รองรับสิ่งนี้โดยตรง แต่ใช้ intrinsic แบบกำหนดเอง
Dasn1-Any แทน
- ทำให้เวลาถอดรหัสจริงยังต้องจัดการด้วยมือ
ภาระข้อมูลที่ล้นเกิน
- เนื่องจากทำหลายโปรเจ็กต์พร้อมกัน ทั้ง ASN.1, x.68x, x.690, Juptune จึงทำให้ รักษาความเข้าใจบริบทของ codebase ได้ยาก
ความเป็นจริงของการสร้างคอมไพเลอร์
- มีทั้งการเขียน visitor ของโหนดนับพันตัว โค้ดซ้ำ ๆ และการทำงานที่ต่างกันเพียงเล็กน้อย ซึ่งเป็น งานที่น่าเบื่อและหนักหนา
- แต่ในแต่ละขั้นตอนก็มี ความรู้สึกสำเร็จและผลลัพธ์ด้านการเรียนรู้สูงมาก
- ผู้เขียนย้อนมองว่า “คงไม่มีใครใช้มัน แต่ฉันก็ได้ประสบการณ์คอมไพเลอร์ของจริง”
- ตอนท้ายยังปิดบทความด้วยมุกว่า “อย่าไปยุ่งกับ ASN.1 เลย ชีวิตคุณจะเปลี่ยน”
บทสรุป
- แม้ทำงานมานาน 1 ปี dasn1 ก็ยังไม่เสร็จสมบูรณ์
แต่ก็เป็นจุดเปลี่ยนที่ทำให้เข้าใจอย่างลึกซึ้งถึงศักยภาพของภาษา D และความซับซ้อนของ ASN.1
- ผู้เขียนปิดท้ายอย่างมีอารมณ์ขัน โดยฝันว่าสักวันจะได้เขียนในเรซูเม่ว่า “มีประสบการณ์ทำ ASN.1 compiler + TLS 1.3 implementation” พร้อมย้อนมอง การเติบโตของนักพัฒนาและความจริงของอุตสาหกรรม
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
สรุปคืออยากพูดถึง ASN.1, ภาษา D และตัวคอมไพเลอร์เอง
แต่เพราะหาฟอร์แมตที่สม่ำเสมอไม่ได้ เลยรวบรวมความคิดที่เกี่ยวข้องมาเป็นบล็อกโพสต์
งานยังไม่ถึงกับสมบูรณ์นัก แต่เป็นหัวข้อที่ยากจะเล่าให้สั้น ขออภัยไว้ก่อน
ถ้ามองทางคณิตศาสตร์
{0} ∪ ({2} ∩ {4,5,6,7,8}) = {0}ดังนั้นสุดท้ายจึงอนุญาตได้แค่ค่าเดียวส่วนตัวชอบ D มาก แต่ในโลกความเป็นจริง Go กับ Rust ถูกใช้งานแพร่หลายกว่ามาก
เลยเข้าใจความลำบากของผู้เขียนอย่างลึกซึ้ง
ผมรัก D แต่ไม่ได้จับมานานแล้ว
ยิ่งเคยมีประสบการณ์ทำพาร์เซอร์และการ implement โปรโตคอลมาก่อน ก็ยิ่งรู้สึกน่าสนใจ
พอเห็นคำว่า “OMG ASN.1” ก็รู้สึกยินดีมากที่เจอหัวข้อนี้
ผมยังจำยุคที่อินเทอร์เน็ตกำลังเติบโต และ IETF กำลังพัฒนาโปรโตคอลต่างๆ ได้
ตอนนั้นภาคธุรกิจยังไม่สนใจอินเทอร์เน็ต และฝั่งวิชาการกับ IETF เป็นคนขับเคลื่อน
แต่พอบริษัทต่างๆ รู้ว่ามันทำเงินได้ Protocol Wars ก็เริ่มขึ้น
ASN.1 เป็นผลผลิตของสงครามนั้น และเป็นตัวอย่างของการปะทะกันระหว่างวัฒนธรรมองค์กรกับวัฒนธรรมวิชาการ
ฝั่งองค์กรอาจเปรียบได้กับ ‘วัฒนธรรมสูตรสำเร็จ’ ส่วนฝั่งวิชาการคือ ‘วัฒนธรรมเชิงฟังก์ชัน’
ความต่างของวิธีคิดนี้ยังให้แง่มุมกับวัฒนธรรมการพัฒนา AIในปัจจุบันด้วย
แค่คิดว่าเราอาจลงเอยด้วยระบบที่อยู่แบบ “CN=wikipedia, OU=org, C=US” แทนอินเทอร์เน็ตทุกวันนี้ก็ขนลุกแล้ว
ที่จริงแล้ว ITU และ ISO ต่างหากที่เป็นศูนย์กลาง
หลังจากนั้นช่วงปลายยุค 90 ก็ยังมี ‘สงครามโปรโตคอล’ อีกครั้ง และคราวนี้ IETF เป็นฝ่ายแพ้
ISO มุ่งสู่ความสมบูรณ์แบบจนช้าเกินไป ขณะที่ IETF เดินเร็วด้วยแนวคิดว่า “ค่อยแก้ทีหลัง”
ผลก็คือเกิดปัญหาโปรโตคอลแข็งตัวเปลี่ยนแปลงยาก
อีกทั้ง implementation ของ ASN.1 สำหรับ C ในยุค 1990 ก็แย่มากเช่นกัน
มีสุภาษิตตุรกีที่พูดว่า “นี่ไม่ใช่ของที่มนุษย์จะใช้กัน!”
ผมอยากยึดประโยคนี้เป็นคติประจำใจด้านปรัชญาการออกแบบ
อีกทั้งเหมือนประโยคใน Game of Thrones ที่ว่า “ผู้ตัดสินต้องเป็นคนลงดาบเอง”
คนที่เขียนสเปกควรต้องลงมือ implement พาร์เซอร์เองด้วย
ถ้าเปลี่ยนให้สเปกจะผ่านการอนุมัติได้ก็ต่อเมื่อมีพาร์เซอร์ที่ใช้งานได้จริงและมีชุดทดสอบแนบมาด้วย คุณภาพน่าจะดีขึ้นมาก
ผมชอบภาษา D มากจริงๆ
ตอนนี้กำลังเขียนโปรแกรมแก้ไขข้อความสไตล์ vimเอง โดยพึ่งพาแค่ Raylib
ข้อดีของ D มีดังนี้
version(unittest)เพื่อจัดการโค้ดเฉพาะสำหรับการทดสอบได้ง่ายไม่ว่าจะเปิดเอกสารอ่านหรือถาม ChatGPT ผมก็หาทางออกที่สวยงามได้เสมอ
ในเชิงปรัชญาการออกแบบมันเกือบสมบูรณ์แบบ แต่ถ้าเครื่องมือและ ecosystem ไปถึงระดับ Rust หรือ Go ได้ มันคงประสบความสำเร็จกว่านี้มาก
ส่วนไลบรารีมาตรฐาน Phobos ก็มีจุดจุกจิกเล็กๆ น้อยๆ มากเกินไปจนสุดท้ายผมเลิกใช้
ตอนนี้มี Phobos V3 รุ่นใหม่กำลังพัฒนาอยู่ แต่เพราะคนทำน้อยก็เลยทั้งหวังทั้งกังวล
“ผมเคยพูดหรือว่า ASN.1 มันซับซ้อน?”
ทั้งสคีมาและฟอร์แมตข้อมูลนั้นซับซ้อน แต่ส่วนใหญ่เป็นความซับซ้อนที่มองข้ามได้
ผมไม่ได้ใช้สัญกรณ์สคีมาของ ASN.1 และเขียนimplementation ของ DERขึ้นมาเองด้วย C
ผมคิดว่า DER เป็น encoding มาตรฐานเพียงแบบเดียวที่ใช้งานได้จริง
นอกจากนี้ยังเคยสร้างฟอร์แมต encoding ของตัวเองอย่าง DSER, SDSER, TER ด้วย
โครงสร้างอย่าง
ANY DEFINED BYก็ยังใช้อยู่และยังมีประโยชน์มากและเพื่อให้ encoding มีประสิทธิภาพขึ้น ผมยังเพิ่มฟีเจอร์นอกมาตรฐานชื่อ OBJECT IDENTIFIER RELATIVE TO เข้าไปด้วย
ผมเองก็เคยสร้าง ASN.1 compiler มาก่อน
แม้จะ implement แค่บางส่วนของ X.681~X.683 แต่ก็ทำให้สามารถถอดรหัสใบรับรองทั้งใบแบบ recursive ได้ด้วยการเรียก codec แค่ครั้งเดียว
ASN.1 ไม่ใช่แค่ไวยากรณ์ธรรมดา แต่เป็นระบบชนิดข้อมูลที่ทรงพลัง
มันถูกประเมินค่าต่ำไป แต่เป็นเทคโนโลยีที่เจ๋งมากจริงๆ
ผมเคยสร้าง ASN.1 compiler สำหรับ Swift มาก่อน
ในโปรเจกต์ ASN1Codable โดยใช้ libasn1 ของ Heimdal
เพื่อแปลง ASN.1 เป็นJSON ASTและทำให้การพาร์เซอร์ง่ายขึ้น
คำว่า “แปลงเป็น JSON กันเถอะ” ฟังดูเหมือนเสียงร้องของนักพัฒนาที่บอบช้ำมาแล้ว 😄
แปลกดีที่การทำงานกับ ASN.1 ให้ความรู้สึกสนุก
สักวันหนึ่งผมก็อยากลองสร้าง ASN.1 compiler สำหรับ Rust เองบ้าง
implementation ของ Rust ตอนนี้ส่วนใหญ่ยังเป็นแบบ derive macro หรือเชื่อมต่อกันด้วยมือ ซึ่งน่าเสียดาย
โดยทั่วไปเวลา implement มาตรฐาน เรามักทำฟีเจอร์ได้ 80% ในเวลา 20%
แต่ 20% ที่เหลือของ ASN.1 อาจใช้เวลาทั้งชีวิตก็ได้
เมื่อก่อนผมเคยขยาย ASN.1 parser ในโค้ดเบสของ Netscape เพื่อรองรับPKCS#12
แม้จะรู้ลึกเรื่องมาตรฐาน RSA และนิยาม ASN.1 จนแอบเสียดายอยู่บ้าง
แต่ก็ขอคารวะต่อความอึดและความมาโซนิดๆของคนเขียนบล็อก