Private Docs

ชั้น 3 — เข้ารหัส payload ระหว่างทาง (JWE) : สเปคสำหรับรีวิว

สเปคฉบับลงมือทำได้จริงของการเข้ารหัสเนื้อคำขอจากเบราว์เซอร์ถึง backend — อัลกอริทึม กุญแจ ลำดับการทำงานกับชั้นลายเซ็น สัญญาของ error และรายการ field ที่ควรเข้ารหัส · เขียนเป็นภาษาไทยสำหรับให้ Owner รีวิวและเซ็นรับ

อัปเดต: 2026-08-27

หน้านี้มีไว้ให้รีวิว — ถ้าคุณเซ็นรับสเปคนี้ เล่ม 2 (Encryption Field) จะครบ 8/8 = 100% ของเฟส local

จุดที่ต้องใช้ดุลพินิจของคุณอยู่ที่ §3 (กุญแจ) · §5 (ลำดับกับลายเซ็น) · §6 (สัญญาของ error) · §7 (เข้ารหัส field ไหน) และ §10 (ของที่ยังไม่ปิด) — ส่วนที่เหลืออ่านเพื่อเข้าใจบริบท

โค้ดทุกบล็อกในหน้านี้คือของที่ implement และพิสูจน์ไปแล้วจริง ไม่ใช่ข้อเสนอ

1. ชั้นนี้คืออะไร และต่างจากอีก 2 ชั้นที่มีอยู่แล้วยังไง

ระบบมีการเข้ารหัส/ป้องกัน 3 ชั้นที่ แก้คนละปัญหา — สเปคนี้เพิ่มชั้นที่ 3 และไม่แตะ 2 ชั้นแรก

ชั้นป้องกันอะไรตอบคำถามว่า
1 · เข้ารหัสตอนเก็บ (at-rest)ข้อมูลหลุดจากฐานข้อมูล/backup”ถ้าฐานข้อมูลหลุด อ่านออกไหม”
2 · ลายเซ็นเนื้อคำขอปลอมคำขอ · แก้ระหว่างทาง · ยิงซ้ำ”คำขอนี้มาจากใคร และถูกแก้ไหม”
3 · เข้ารหัส payload (หน้านี้)คนกลางระหว่างทางอ่านเนื้อหาไม่ได้”ระหว่างเบราว์เซอร์ถึง backend มีใครแอบอ่านได้ไหม”

🔑 ชั้น 2 กับ 3 ไม่ทดแทนกัน — คำขอที่เซ็นแล้วยังเป็นข้อความอ่านออกได้ ถ้าไม่มีชั้น 3 · และข้อมูลที่เข้ารหัสตอนเก็บ (ชั้น 1) ก็ยังวิ่งผ่านเน็ตเวิร์กแบบอ่านออกได้ ถ้าไม่มีชั้น 3 field หนึ่งอาจต้องการชั้นไหนก็ได้ หรือหลายชั้นพร้อมกัน — ตัดสินจากความอ่อนไหวของข้อมูล ไม่ใช่จากว่ากลไกไหนมีอยู่แล้ว (ดู §7)

2. อัลกอริทึม — ตัดสินไปแล้ว ไม่ต้องเลือกใหม่

  • RSA-OAEP-256 สำหรับห่อกุญแจ + A256GCM สำหรับเข้ารหัสเนื้อหา · รูปแบบ JWE Compact (5 ส่วนคั่นด้วยจุด: header.encryptedKey.iv.ciphertext.tag)
  • ของทั้งหมดอยู่ใน library กลาง SupApp_util_lib ตั้งแต่เวอร์ชัน 10.12.0 แล้ว — ไม่มีการเขียน crypto ใหม่ในงานนี้เลย งานคือการต่อสายเข้ากับ endpoint จริง

🔴 จุดออกแบบที่สำคัญที่สุดของชั้นนี้ และเป็นสิ่งที่ผมอยากให้คุณดูเป็นพิเศษ: สาเหตุความล้มเหลวของการถอดรหัสมี 4 แบบ (padding ผิด · tag ผิด · ไม่รู้จักกุญแจ · รูปแบบเพี้ยน) แต่ระบบ ยุบทั้ง 4 แบบให้เหลือ error เดียวที่หน้าตาเหมือนกันทุกไบต์

เหตุผล: ถ้าแยกความต่างได้ คนร้ายจะใช้ error เป็น “เครื่องบอกใบ้” ค่อย ๆ เดาไปทีละส่วนจนถอดรหัสได้โดยไม่ต้องมีกุญแจ (padding oracle) — พิสูจน์แล้วตอน BE-7 ว่าการแก้ ciphertext 1 ไบต์ กับการชี้ไปกุญแจที่ไม่มีอยู่ ให้คำตอบเหมือนกันเป๊ะ ⇒ กติกาถาวร: ห้ามเขียนโค้ดที่แตกแขนงตามสาเหตุความล้มเหลว และห้ามส่งข้อความ error ของ library ออกไปทางเน็ตเวิร์ก

3. กุญแจอยู่ที่ไหน

ครึ่งเก็บที่เปิดเผยได้ไหม
สาธารณะ (ใช้เข้ารหัส)config FieldEncryption:Payload:Keys:{kid} — เป็น base64 ของ DER แบบ SubjectPublicKeyInfo✅ เปิดเผยผ่าน GET /api/user-service/v1/security/jwks โดยตั้งใจ ใครก็ดึงได้
ส่วนตัว (ใช้ถอดรหัส)config FieldEncryption:Payload:PrivateKeys:{kid} — base64 ของ DER แบบ PKCS8🔴 ไม่เคยออกจาก server · ต้องอยู่ใน Key Vault เท่านั้น

kid เดียวกันต้องมีอยู่ทั้ง 2 ฝั่ง — ถ้าประกาศกุญแจใน JWKS แล้วไม่มีคู่ฝั่งถอด = ตั้งค่าผิด ไม่ใช่บั๊กตอนรัน

กับดัก 2 ข้อที่เกือบทำให้พังตอนขึ้น production

① กุญแจถูกทิ้งก่อนได้ใช้ — โค้ดฝั่ง JWKS ใช้รูปแบบ “สร้าง RSA แล้วทิ้งทันที” ซึ่งถูกต้องสำหรับกุญแจสาธารณะ (เอาแค่ค่าตัวเลขออกมาแล้วไม่ใช้ object ต่อ) แต่ผิดร้ายแรงถ้าเอามาใช้กับกุญแจส่วนตัว เพราะฝั่งถอดรหัสจะเรียกใช้ object นั้น หลังจาก method คืนค่าไปแล้ว ⇒ ถ้า copy รูปแบบเดียวกันมา ทุกคำขอที่เข้ารหัสจะพังเป็น 500 ทั้งหมด โดยที่ทุกอย่างดูเหมือนต่อสายถูกแล้ว ⇒ กุญแจส่วนตัวต้องถูกอ่านครั้งเดียวตอนระบบ start แล้วถือไว้ตลอดอายุ process (จุดนี้ถูกจับได้จากการรีวิวสเปค ก่อน เขียนโค้ด ไม่ใช่หลังจากพัง)

② ตั้งค่าผิดต้องพังตั้งแต่ start ไม่ใช่พังตอนมีคนใช้ — ถ้าเปิดสวิตช์แล้วไม่มีกุญแจ ระบบต้อง ไม่ยอม start · ถ้าปล่อยให้ start ได้ มันจะขึ้นเขียวสวยงามแล้วตอบ 400 ให้ทุกคำขอที่เข้ารหัสตลอดไป ซึ่งดูเหมือนบั๊กตอนรัน ทั้งที่จริงเป็นการตั้งค่าผิดที่ควรจับได้ตั้งแต่ตอน boot

③ ข้อความ error ตอน start ห้ามมีค่าของกุญแจ — ให้บอกแค่ชื่อ kid · ถ้า interpolate ค่าเข้าไปในข้อความ กุญแจส่วนตัวจะไปโผล่ใน log ของ pod ตอน crash loop

4. หน้าตาของคำขอที่เข้ารหัส

เนื้อคำขอทั้งก้อนคือสตริง JWE ตรง ๆ — ไม่ใช่ JSON ที่ห่อ JWE ไว้ข้างใน ไม่ใช่ {"payload": "..."}

POST /api/user-service/v1/users
Content-Type: application/json

eyJhbGciOiJSU0EtT0FFUC0yNTYi....AbCdEf.GhIjKl.MnOpQr.StUvWx      ← 5 ส่วนคั่นด้วยจุด

Content-Type ยังเป็น application/json เหมือนเดิมทั้ง 2 แบบ — ระบบดูจากรูปร่างของเนื้อหา ไม่ได้ดูจาก header (การเพิ่ม content type ใหม่จะลากงาน content negotiation ที่ระบบนี้ไม่ได้ใช้ที่อื่นเลยเข้ามา)

วิธีแยกว่าเป็นแบบไหน: JSON ที่ถูกต้องขึ้นต้นด้วย { [ " ตัวเลข หรือ t/f/n เสมอ · ส่วน JWE เป็น base64url 5 ส่วนคั่นจุด ซึ่งไม่มีทางขึ้นต้นแบบนั้น ⇒ แยกได้ชัดเจนโดยไม่ต้องเดา

5. ลำดับการทำงานกับชั้นลายเซ็น — 🔴 จุดที่ต้องรีวิว

ข้อกำหนด: ตรวจลายเซ็นก่อน แล้วค่อยถอดรหัส

ฝั่ง client :  สร้าง JSON  →  เข้ารหัสเป็น JWE  →  เซ็นทับก้อนที่เข้ารหัสแล้ว
ฝั่ง server :  ตรวจลายเซ็นจากก้อนที่ยังเข้ารหัสอยู่  →  ผ่านแล้วค่อยถอด

ทำไมต้องลำดับนี้: ถ้าถอดก่อนตรวจ = เราเสียแรงถอดรหัสให้ก้อนที่ยังไม่รู้ว่ามาจากใคร และถ้าเผลอมีโค้ดไหน log หรือแตะ plaintext ก่อนตรวจเสร็จ ก็เท่ากับเปิดเผยข้อมูลของคำขอที่อาจเป็นของปลอม

ปัญหาทางเทคนิคที่ต้องแก้: ตัวตรวจลายเซ็นเป็น action filter ซึ่งใน ASP.NET Core ทำงานหลังขั้นตอน model binding ⇒ ถ้าเอาการถอดรหัสไปไว้ที่ model binding (วิธีที่ดู “สะอาด” ที่สุด) มันจะรันก่อนตรวจลายเซ็น = สลับลำดับที่ต้องการพอดี

ทางแก้ที่เลือก: ไม่ผูก parameter ด้วย [FromBody] เลย แต่อ่านและถอดรหัสเองในตัว method ของ controller เพราะเนื้อ method จะทำงานหลังจาก filter ทุกตัวเสร็จแล้วเสมอ — เป็นการรับประกันด้วยโครงสร้างของ pipeline ไม่ใช่ด้วยการไปตั้งเลขลำดับให้ filter (ซึ่งเปราะและคนหลังมาแก้พลาดได้ง่าย)

⚠️ ราคาที่ต้องจ่ายของทางแก้นี้ และเป็นสิ่งที่ผมอยากให้คุณรับทราบ: การถอด [FromBody] ทำให้ Swagger ไม่แสดงหน้าตา request ของ endpoint นี้ (แก้เพิ่มด้วย [Consumes] ไปแล้วในภายหลัง) และทำให้ เสียกลไกแปลง JSON พังเป็น 400 อัตโนมัติ ที่ [ApiController] เคยให้ฟรี ⇒ ต้อง catch เองในโค้ด ไม่งั้นเคส “ส่ง JSON พัง ตอนปิดสวิตช์” จะกลายเป็น 500 ทั้งที่วันนี้เป็น 400 (จุดนี้ถูกจับได้จากการรีวิวก่อนเขียนโค้ดเช่นกัน)

6. สัญญาของ error — 🔴 จุดที่ต้องรีวิว

สถานการณ์ตอบอะไรเหตุผล
ปิดสวิตช์ · ส่ง JSON ปกติทำงานเหมือนเดิมทุกประการปิดแล้วต้องไม่เปลี่ยนอะไรเลย
ปิดสวิตช์ · ส่ง JSON พัง400 InvalidRequestBodyต้องเหมือนพฤติกรรมวันนี้ (ถ้าไม่ catch เองจะกลายเป็น 500)
เปิดสวิตช์ · ส่ง JSON ปกติทำงานเหมือนเดิมรับได้ทั้ง 2 แบบ (dual-accept) ระหว่างช่วงเปลี่ยนผ่าน
เปิดสวิตช์ · ส่ง JWE ถอดได้ทำงานตามปกติเส้นทางที่ตั้งใจ
เปิดสวิตช์ · ส่ง JWE ถอดไม่ได้ (ทุกสาเหตุ)400 PayloadDecryptionError ข้อความคงที่fail-closed — ห้ามถอยไปอ่าน ciphertext เป็น plaintext เด็ดขาด
เปิดสวิตช์ · JWE ถอดได้ แต่ข้างในไม่ใช่ JSON400 InvalidRequestBody⚠️ ใครก็ทำเคสนี้ได้ เพราะกุญแจสาธารณะเปิดให้ทุกคน ⇒ ต้อง 400 สะอาด ๆ ไม่ใช่ 500

🔴 กฎข้อเดียวที่ห้ามละเมิดไม่ว่ากรณีใด: ถอดรหัสไม่สำเร็จ = ปฏิเสธคำขอ ห้ามเอา ciphertext ไปใช้เป็นข้อมูลคำขอไม่ว่าจะแปลงยังไง เพราะถ้าถอยไปอ่านเป็น plaintext ได้ คนที่สร้าง ciphertext ที่ถูกต้องไม่ได้ ก็แค่ส่งข้อมูลดิบแบบไม่เข้ารหัสมาแทน แล้วระบบก็รับ — เท่ากับไม่มีการเข้ารหัสตั้งแต่แรก (เป็น anti-pattern เดียวกับที่เพิ่งปิดไปใน BE-19)

🔴 ห้ามเขียน plaintext ที่ถอดได้ลง log หรือ diagnostic context ใด ๆ — ระบบมีตัว enrich log ที่หยิบเนื้อคำขอไปใส่ Serilog อยู่ · จุดประสงค์ทั้งหมดของชั้นนี้คือไม่ให้เลขบัตร/วันเกิด/ที่อยู่ วิ่งผ่านเน็ตแบบอ่านออกได้ การถอดแล้วเขียนลง log ทำลายมันพอ ๆ กับไม่เข้ารหัสเลย

7. เข้ารหัส field ไหน — 🔴 จุดที่ต้องรีวิว (เป็นการตัดสินใจเชิงนโยบาย ไม่ใช่เทคนิค)

ยึดตามหลักที่คุณวางไว้เองว่า “encryption field ขึ้นอยู่กับ data” ⇒ ใช้ผลการจำแนกข้อมูลของ BE-18 ไม่ใช่เดาใหม่

ระดับข้อมูลชั้นนี้เอาไหม
สูงสุดเลขบัตรประชาชนใน DataJson (3 ตาราง)ไม่เอา — คุณตัดสินไปแล้วว่าให้ใช้กลไก at-rest · เอามาซ้ำที่นี่ = เข้ารหัสข้อมูลชุดเดียวกัน 2 กลไกโดยไม่มีเหตุผล
ควรทำวันเกิด · สัญชาติ · ที่อยู่เต็มชุด (บ้านเลขที่ ตำบล อำเภอ จังหวัด รหัสไปรษณีย์ อาคาร ซอย ถนน)นี่คือกลุ่มเป้าหมายของชั้นนี้ — มี endpoint เขียนจริง (POST /users, PUT /users/me)
ควรทำ แต่ยังไม่มีทางเข้าเบอร์โทรใน UserTemp (เขียนจากระบบภายนอก) · ข้อมูลพนักงาน (sync จาก HR ทางเดียว)⏸️ ยังไม่มี endpoint ให้ต่อ ค่อยกลับมาดูเมื่อมี
ไม่ต้องอีเมล · เลขทะเบียนนิติบุคคล❌ เป็นคีย์ที่ใช้ค้นหา หรือเป็นข้อมูลสาธารณะจากทะเบียน · เข้ารหัสแล้วจะค้นหาไม่เจอ = ทำระบบพัง ไม่ใช่ทำให้ปลอดภัยขึ้น

คำถามที่ผมอยากได้คำตอบจากคุณตรงนี้: เห็นด้วยไหมว่า ที่อยู่ + วันเกิด + สัญชาติ คือสิ่งที่ควรเข้ารหัสระหว่างทาง และ อีเมลไม่ต้อง — ถ้าเห็นต่างข้อไหน บอกได้ เพราะมันเปลี่ยนขอบเขตงานถัดไป

8. ตัวอย่างที่ทำจริงแล้ว — POST /api/user-service/v1/users

เลือก endpoint นี้เพราะ: แตะข้อมูลกลุ่ม “ควรทำ” ครบชุด · ถูกเรียกครั้งเดียวตอนสร้างผู้ใช้ (ผลกระทบแคบกว่า endpoint แก้โปรไฟล์ที่เรียกซ้ำได้) · มีการยืนยันตัวตนอยู่แล้ว · และไม่ชนกับ 3 เส้นที่คุณสั่งห้ามแตะ

ผลการยิงจริงบนเครื่อง (27/08):

เคสผล
ส่งแบบเข้ารหัส201 — สร้างผู้ใช้สำเร็จ
เปิดฐานข้อมูลดูค่าที่บันทึกตรงกับที่เบราว์เซอร์เข้ารหัสส่งไปทุกตัว ⇒ ถอดรหัสได้จริง ไม่ใช่แค่ตอบ 201
ส่งแบบ plaintext201 — ของเดิมไม่พัง
แก้ ciphertext 1 ไบต์400 PayloadDecryptionError

9. สิ่งที่ชั้นนี้ ไม่ ทำ (ตั้งใจ)

ไม่มีกลไกกันยิงซ้ำหรือกันคำขอเก่าเป็นของตัวเอง — เรื่องนั้นเป็นหน้าที่ของชั้นลายเซ็นซึ่งมีอยู่แล้ว เหตุผล: การเข้ารหัสคือการกัน “อ่าน” ไม่ใช่กัน “ซ้ำ” · ถ้าใส่ timestamp/nonce ซ้อนเข้าไปในชั้นนี้อีกชุด จะกลายเป็นมี 2 หน้าต่างเวลาที่เพี้ยนออกจากกันได้ แล้วตอนเกิดเหตุจะไม่มีใครรู้ว่าต้องเชื่ออันไหน

⚠️ ข้อยกเว้นที่ต้องระวัง: ถ้าวันหน้าเอาชั้นนี้ไปใช้กับ endpoint ที่ไม่มีชั้นลายเซ็น เส้นนั้นจะไม่มีการกันยิงซ้ำเลย — ต้องยกมาคุยกันก่อน ไม่ใช่ทำเงียบ ๆ

10. ของที่รู้ตัวว่ายังไม่ปิด — 🔴 จุดที่ต้องรีวิว

  1. ยังทำแค่ 1 endpoint — เส้นอื่นที่แตะข้อมูลกลุ่มเดียวกัน (เช่นหน้าแก้โปรไฟล์) ยังไม่ได้ต่อ
  2. Swagger ของ endpoint นี้เคยหาย จากการถอด [FromBody] — แก้แล้วภายหลัง แต่ endpoint ถัดไปที่ทำตามแบบนี้จะเจอปัญหาเดิม ต้องจำไว้
  3. ยังไม่มีการหมุนกุญแจ — โครงรองรับหลาย kid พร้อมกันอยู่แล้ว แต่ยังไม่มีขั้นตอนปฏิบัติว่าจะหมุนเมื่อไหร่ ใครทำ
  4. ยังไม่ทดสอบบน cloud — ทุกอย่างในหน้านี้พิสูจน์บนเครื่อง local เท่านั้น ตามที่คุณกำหนดว่าเฟสนี้ local ก่อน

สิ่งที่ผมอยากได้จากการรีวิวของคุณ

#คำถามอยู่หัวข้อ
1การเก็บกุญแจส่วนตัว + กติกา “ตั้งค่าผิดต้องไม่ยอม start” รับได้ไหม§3
2ลำดับ “ตรวจลายเซ็นก่อน ถอดทีหลัง” และราคาที่จ่ายไป (Swagger + ต้อง catch เอง) ยอมรับได้ไหม§5
3สัญญาของ error โดยเฉพาะการยุบทุกสาเหตุให้เหลือข้อความเดียว — เห็นด้วยไหม§2, §6
4รายการ field ที่จะเข้ารหัส (ที่อยู่/วันเกิด/สัญชาติ เอา · อีเมล ไม่เอา) ตรงกับที่คุณคิดไหม§7
5ของที่ยังไม่ปิด 4 ข้อ — ยอมรับให้เลื่อนได้ทั้งหมดไหม หรือมีข้อไหนต้องทำก่อน§10

ตอบ 5 ข้อนี้ = เซ็นรับสเปคได้ · ต้นฉบับภาษาอังกฤษฉบับเต็ม (พร้อมโค้ดและอ้างอิงถึงบรรทัดในไฟล์จริง) อยู่ที่ Atlas/docs/security/security-uplift-meeting-plan/20260823/PAYLOAD_ENCRYPTION_LAYER3_SPEC.md