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