ข้ามไปที่เนื้อหา

พจนานุกรมการเก็บข้อมูลส่วนบุคคล (PII Field Dictionary)

มาตรฐานกลางของ RLPD Phase 2 — สำรวจจากซอร์สโค้ดจริงทั้ง 9 ระบบ เมื่อ 9 ก.ย. 2569

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

4 ชั้นของการเก็บ PII

ข้อมูลส่วนบุคคลหนึ่งค่า (เช่น เลขบัตร 13 หลัก) ต้องทำได้ 4 อย่างพร้อมกัน และแต่ละอย่างต้องใช้คอลัมน์ของตัวเอง — ไม่มีคอลัมน์เดียวที่ทำได้ครบ

ชั้น คอลัมน์ ใช้ทำอะไร คุณสมบัติ
1. ปกปิด (mask) *_masked แสดงบนหน้าจอ อ่านได้ ไม่ใช่ความลับ
2. ค้นหา/แสดงบางส่วน *_last4 กรองแถว ค้นหาแบบหยาบ index ได้
3. จับคู่/กันซ้ำ *_hash หรือ blind index ตรวจว่าเป็นคนเดียวกันไหม บังคับ unique deterministic — ค่าเดียวกันได้ผลเดียวกันเสมอ
4. เก็บค่าจริง *_enc เอาเลขเต็มกลับมาใช้ (เช่น พิมพ์บัตร) ถอดกลับได้ ด้วยกุญแจ

ข้อสำคัญที่สุด: ชั้น 3 กับชั้น 4 ทำแทนกันไม่ได้

  • *_hash ถอดกลับไม่ได้ — ใช้ตรวจซ้ำได้ แต่เอาเลขเต็มคืนมาไม่ได้ตลอดกาล
  • *_enc ใช้ IV สุ่มทุกครั้ง ค่าเดียวกันได้ ciphertext ต่างกัน (ตั้งใจ — ไม่ให้รั่วว่าสองแถวเป็นคนเดียวกัน) จึงค้นด้วยค่าตรง ๆ ไม่ได้

ระบบที่มีแต่ *_hash (ชั้น 3) จะ ไม่มีวัน เอาเลขเต็มกลับมาได้ ถ้าต้นทางถูกปิด — ดู S4 ด้านล่าง


สถานะปัจจุบันของแต่ละระบบ

ระบบ 1 mask 2 last4 3 hash/bidx 4 enc หมายเหตุ
S1 LCS ✅ ✅ ✅ blind index (scrypt) ✅ AES-256-GCM ครบที่สุด — เข้ารหัสชื่อ/เบอร์/อีเมล/ที่อยู่ ด้วย
S2 PJOS ✅ ✅ ❌ ✅ AES-256-GCM ตาม NFR-S02 · กันซ้ำโดย decrypt เฉพาะแถวที่ last4 ตรง
S4 eMediation ✅ ❌ ✅ HMAC-SHA256 ❌ ไม่มีชั้น 4 — เลขเต็มหายถาวรหลัง migration
S9 Web Portal ❌ ❌ ❌ ❌ เก็บดิบ Idcard NVARCHAR(20) (S4 มา mask+hash ตอน import)
S3 AR-CRS — — — — ไม่เก็บเลขบัตร · citizen_id เป็น claim ของ Keycloak และอยู่ใน รายการห้าม log
S5 / S7 — — — — S7 ใช้ CitizenId เป็น claim จาก SSO เท่านั้น ไม่พบคอลัมน์เก็บ

กุญแจของแต่ละระบบ (ค่าจริงอยู่นอก git เสมอ)

ระบบ ตัวแปร หน้าที่
S1 LCS_FIELD_KEY เข้ารหัส (ชั้น 4)
S1 LCS_BIDX_KEY blind index (ชั้น 3) — คนละกุญแจโดยตั้งใจ
S2 PJOS_FIELD_KEY เข้ารหัส (ชั้น 4)
S4 IDCARD_HASH_SECRET HMAC (ชั้น 3)

ทำไม S1 แยกกุญแจเป็น 2 ดอก

blind index กับ ciphertext ป้องกันคนละเรื่อง — ค้นหา/กันซ้ำ กับ ความลับ ถ้ากุญแจดอกหนึ่งรั่วหรือถูกเปลี่ยน อีกดอกต้องไม่อ่อนลงตาม เป็นแบบที่ควรใช้เป็นมาตรฐาน

กุญแจหาย = ข้อมูลหาย

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


ปัญหาที่พบจากการสำรวจ

1. รูปแบบ mask ไม่ตรงกันสักระบบ

ที่มา ผลลัพธ์ ปัญหา
S1 LCS *********0123 ใช้ * · ความยาวบอกใบ้จำนวนหลักของค่าเดิม
S2 PJOS X-XXXX-XXXXX-01-23 แสดง 14 ตำแหน่ง ทั้งที่เลขบัตรมี 13 หลัก
S4 eMediation XXXXXXXXX0123 ความยาวบอกใบ้จำนวนหลักของค่าเดิม
มาตรฐานจริง (THAI_CITIZEN_ID_MASK ใน rlpdjs) X-XXXX-XXXXX-XX-X กลุ่ม 1-4-5-2-1 = 13 หลัก ✅

ไม่มีระบบไหนตรงกับมาตรฐานที่ rlpdjs ประกาศไว้เอง และ mask ที่ยาวตามค่าจริงจะบอกใบ้ว่า ค่าเดิมมีกี่หลัก ซึ่งสำคัญเพราะข้อมูลเก่าที่นำเข้ามามี 274 แถวที่เลขบัตรไม่ครบ 13 หลัก

2. S4 ไม่มีชั้น 4 — และนี่คือเรื่องที่มีกำหนดเวลา

S4 พิมพ์เลขบัตรเต็มลง บัตรผู้ไกล่เกลี่ย และ คำสั่งแต่งตั้ง (ตรวจจากซอร์ส V1: Mediator_card.php, Mediator_appointment.php) แต่ V3 เก็บแค่ mask + hash ซึ่งถอดกลับไม่ได้

เลขเต็มมีอยู่ที่เดียวคือระบบเดิม (legacy prod) ต้อง backfill เข้าคอลัมน์เข้ารหัส ก่อนปิดระบบเดิม มิฉะนั้นหายถาวร

ขอบเขตที่แนะนำ: เข้ารหัสเฉพาะ ทะเบียนผู้ไกล่เกลี่ย (~8,000 ราย) ไม่ใช่ทุกคนที่นำเข้า (~30,000 ราย) — เพราะไม่มีเอกสารใดพิมพ์เลขเต็มของคณะทำงานศูนย์หรือคู่กรณี

3. S9 เก็บดิบ

Idcard NVARCHAR(20) ไม่มีการปกปิดหรือเข้ารหัส S4 จึง mask+hash ให้ตอน import เป็นรายการที่ควรพิจารณายกระดับ


ทิศทางที่ตกลง — ใช้ตัวเดียวร่วมกัน

rlpdjs 0.19.0 เพิ่ม FieldCryptoModule — ยกมาจาก S2 PJOS (NFR-S02) ให้เป็นสำเนากลาง ที่ทุกระบบใช้ร่วมกันแทนที่จะต่างคนต่างเขียน

FieldCryptoModule.forRoot({ key: process.env.PJOS_FIELD_KEY })   // S2
FieldCryptoModule.forRoot({ key: process.env.FIELD_ENCRYPTION_KEY })

สิ่งที่คงไว้จาก S2 โดยตั้งใจ

  • โครง blob: iv(12) || authTag(16) || ciphertext เก็บใน VARBINARY
  • IV สุ่มทุกครั้ง — ค่าเดียวกันได้ ciphertext ต่างกัน
  • เลือกเข้ารหัสที่ชั้นแอป แทน Always Encrypted (ชนกับ ORM) และแทน TDE อย่างเดียว (TDE ป้องกันไฟล์ ไม่ป้องกันการอ่านผ่าน connection ปกติ)
  • ไม่มีการ boot แบบไม่ตั้งค่า — กุญแจหายหรือผิดขนาด = ตายตั้งแต่ bootstrap ไม่ใช่เก็บ plaintext เงียบ ๆ

สิ่งที่แก้ระหว่างยก

  • maskCitizenId เดิมจัดกลุ่ม last4 เป็น 2+2 → แสดง 14 ตำแหน่งสำหรับเลข 13 หลัก แก้เป็นกลุ่มจริง 1-4-5-2-1 → X-XXXX-XXXX0-12-3 โดยยังคงคุณสมบัติ "ความยาวคงที่ ไม่บอกใบ้ค่าเดิม"

ยืนยันแล้ว: unit test 19 ข้อ · ทั้งแพ็กเกจ 178 ข้อผ่าน · และ ciphertext ที่ S2 เขียนไว้เดิม ถอดด้วยโมดูลใหม่ได้ตรงทุกไบต์ → S2 ย้ายมาใช้ได้โดยไม่ต้อง migrate ข้อมูล

ยังไม่ได้ทำ

  • blind index กลาง — S1 มี (blindIndex() scrypt + กุญแจแยก) และเป็นคำตอบที่ถูกต้องของชั้น 3 ควรยกขึ้น rlpdjs ต่อไป เพื่อให้ S1/S2/S4 ใช้แบบเดียวกัน
  • ~~S4 idcard_enc~~ — ทำแล้ว (v0.9.26, 10 ก.ย. 2569): เก็บเลขเต็มเข้ารหัสที่ทะเบียน ผู้ไกล่เกลี่ย · หน้าจอแสดง mask · ADMIN + REGISTRAR เปิดดูได้ผ่านปุ่ม "ดูเลขเต็ม" (ทุกครั้งบันทึก audit รวมถึงครั้งที่ถูกปฏิเสธ) · กุญแจติดตั้งใน pipeline แล้ว การ backfill จาก legacy เป็นขั้นตอน cutover — ต้องรันก่อนปิดระบบเดิม

ประเด็นค้าง

[OPEN] รายงานทะเบียนผู้ไกล่เกลี่ย (ชื่อ / เลขบัตร / เบอร์โทร — ส่งออก Excel ได้) ต้องแสดงเลขเต็มหรือ mask ก็พอ · ถ้าเต็ม ใครมีสิทธิ์ดาวน์โหลด

คำตอบนี้ตัดสินว่าจะสร้างช่องทางส่งออกแบบมีสิทธิ์กำกับหรือไม่ต้องสร้างเลย — เอกสารรายบุคคล (บัตร/คำสั่งแต่งตั้ง) ยังพิมพ์เลขเต็มเหมือนเดิมไม่ว่าคำตอบจะเป็นอะไร

หลักปฏิบัติ

  1. ห้าม log เลขบัตร ไม่ว่ารูปแบบใด — S3 ใส่ citizen_id/nationalId ไว้ในรายการห้าม log แล้ว ใช้เป็นแบบอย่างได้
  2. ห้ามใส่ *_enc ใน SELECT ปกติ — ciphertext ที่ไม่มีเหตุผลต้องอ่าน ไม่ควรไหลออกจาก repository layer
  3. ห้ามใส่เลขบัตรในชื่อไฟล์ — V1 ตั้งชื่อ PDF ว่า บัตรผู้ไกล่เกลี่ย_<เลข 13 หลัก>.pdf เลขเดินทางไปกับอีเมล ใช้รหัสผู้ไกล่เกลี่ยแทน
  4. ทุกครั้งที่ decrypt ต้องบันทึก audit — ใคร เมื่อไหร่ ของใคร เพื่ออะไร · เข้ารหัสโดยไม่มี audit คือย้ายปัญหา ไม่ใช่แก้
  5. ตรวจ checksum ก่อนเก็บ — isValidThaiCitizenId() ใน rlpdjs (mod-11)