BE 4 — เข้ารหัสข้อมูลอ่อนไหว: ทั้งใน DB และใน cache
จัดประเภทข้อมูลก่อน แล้วค่อยเข้ารหัส — cipher กลางที่รองรับการหมุนกุญแจ, การเปิดทีละพื้นผิว (DB / Redis) แยกสวิตช์กัน, กติกา fail-closed และช่องโหว่ที่การเข้ารหัสระดับคอลัมน์ปิดไม่ได้
อัปเดต: 2026-08-23
🔴 อย่าเริ่มด้วยการเขียนโค้ด — เริ่มด้วยการทำรายการว่า service คุณเก็บอะไรที่อ่อนไหวบ้าง และมันอยู่ตรงไหน (คอลัมน์ไหน / cache ตัวไหน / log บรรทัดไหน) เพราะ การเข้ารหัสผิดที่ = เสียเวลาแล้วยังไม่ปลอดภัย
1. จัดประเภทข้อมูลก่อน (ครึ่งวัน คุ้มที่สุดในหัวข้อนี้)
ทำตาราง 4 ช่องนี้ให้ครบก่อน:
| ข้อมูล | เก็บที่ไหนบ้าง (ทุกที่ ไม่ใช่ที่เดียว) | ใครต้องอ่านได้ | ค้นหา/เรียงลำดับด้วยไหม |
|---|---|---|---|
| เลขบัตรประชาชน | คอลัมน์ X · JSON ใน Y · cache Z · log ที่ W | ระบบ A เท่านั้น | ไม่ |
ช่องที่ 2 คือช่องที่คนลืมบ่อยที่สุด — ข้อมูลเดียวกันมักถูกก๊อปไปอยู่หลายที่ ในของจริงพบว่าเข้ารหัสคอลัมน์ไปแล้ว แต่ค่าเดียวกันยังนอนเป็น plaintext อยู่ใน คอลัมน์ JSON ของอีก 3 ตาราง เพราะการเข้ารหัสระดับคอลัมน์มองไม่เห็นข้างในก้อน JSON
ช่องที่ 4 สำคัญเพราะ ข้อมูลที่เข้ารหัสแล้วค้นแบบ LIKE ไม่ได้และเรียงลำดับไม่ได้ ถ้ามีหน้าจอที่ค้นด้วย field นั้น ต้องออกแบบเพิ่ม (เช่นเก็บ hash ไว้ค้นแยกต่างหาก) — คิดตอนนี้ ถูกกว่าค้นพบตอน migration เสร็จแล้ว
2. cipher กลาง — มีให้ใช้แล้ว ไม่ต้องเขียนเอง
SupApp_util_lib มี IFieldCipher ที่ออกแบบมาให้ หมุนกุญแจได้ โดยไม่ต้องแปลงข้อมูลทั้งฐานพร้อมกัน: ทุกค่าที่เข้ารหัสแล้วจะพก หมายเลขกุญแจ ที่ใช้เข้ารหัสไปด้วย ⇒ ระบบถอดของเก่าได้ ในขณะที่ของใหม่เข้ารหัสด้วยกุญแจปัจจุบัน
// ต่อสาย — อ่านกุญแจทั้งชุดจาก config แล้วตรวจความถูกต้องทันทีตอน start
services.AddFieldEncryptionCipher(configuration);
// ภายใน library: ตั้งใจให้ config ที่ผิด "พังตั้งแต่ตอน start" ไม่ใช่พังตอนมีคนใช้งานจริง
var currentKeyId = configuration["FieldEncryption:CurrentKeyId"] ?? string.Empty;
var keysById = configuration.GetSection("FieldEncryption:Keys").Get<Dictionary<string, string>>()
// ...แปลง base64 → bytes โดยโยน error ที่ระบุชื่อ key ตัวที่ผิด
;
_ = new AesGcmFieldCipher(keysById, currentKeyId); // eager validation
⚠️ มี 2 กลไกที่ชื่อคล้ายกันมากในโค้ดฐานนี้ —
Encryption:Key(ของเดิม AES-256-CBC ใช้กับ claim/token) กับFieldEncryption:*(ของใหม่ AES-GCM มีหมายเลขกุญแจ) คนละตัว คนละ config ห้ามเอามาปนกัน ถ้าเจอ error เรื่องคีย์ ให้ดูก่อนว่ากำลังพูดถึงตัวไหน
3. เปิดทีละพื้นผิว — แยกสวิตช์ ไม่ใช่สวิตช์เดียว
ข้อมูลชุดเดียวกันไปโผล่หลายที่ ⇒ ต้องเปิดทีละที่ และแต่ละที่ต้องมีสวิตช์ของตัวเอง
| พื้นผิว | สวิตช์ | หมายเหตุ |
|---|---|---|
| คอลัมน์ใน DB | FieldEncryption:AtRest:Enabled | ต้องคิดเรื่อง migration ของข้อมูลเดิม |
| ค่าที่เขียนลง Redis cache | FieldEncryption:Redis:Enabled | ไม่ต้อง migrate — cache หมดอายุแล้วเกิดใหม่เอง ทำง่ายกว่ามาก เริ่มที่นี่ได้ |
🔴 กับดักที่เจอจริงและต้องรู้ก่อนลงมือ: ถ้าลงทะเบียน cipher ของทั้ง 2 พื้นผิวลง ช่อง DI เดียวกัน สวิตช์จะกลายเป็น “ต้องเปิดทั้งคู่ถึงจะทำงาน” โดยไม่มีใครตั้งใจ — เปิด Redis:Enabled=true แล้วเงียบ ๆ ไม่ทำอะไรเลยเพราะ AtRest:Enabled ยัง false วิธีแก้คือ ลงทะเบียนแยก slot:
AddAtRestFieldEncryption(services, configuration); // ช่อง default
AddRedisFieldEncryption(services, configuration); // ช่อง keyed: "redis-field-cipher"
4. Fail-closed เป็นกติกาบังคับ
สวิตช์เปิด + ไม่มีกุญแจ → ต้อง throw ตั้งแต่ตอนลงทะเบียน
ห้ามเขียน plaintext ลงไปเงียบ ๆ เด็ดขาด
และต้องมี test ที่ยืนยันข้อนี้โดยตรง เช่น FlagOnNoKeyMaterial_EncryptThrowsInsteadOfWritingPlaintext
5. Config + ที่เก็บค่า
// appsettings.json — โครงเท่านั้น
"FieldEncryption": {
"CurrentKeyId": "",
"Keys": {},
"AtRest": { "Enabled": false },
"Redis": { "Enabled": false }
}
| key | เก็บที่ไหน | หมายเหตุ |
|---|---|---|
FieldEncryption:AtRest:Enabled · FieldEncryption:Redis:Enabled | appsettings + IaC ทุก env | ไม่ใช่ความลับ |
FieldEncryption:CurrentKeyId | appsettings + IaC ทุก env | เป็นแค่ “ชื่อ” ของกุญแจ ไม่ใช่ตัวกุญแจ |
FieldEncryption:Keys:{keyId} | 🔴 KeyVault เท่านั้น | ตัวกุญแจจริง (base64 32 ไบต์) |
สร้างกุญแจสำหรับเครื่องตัวเอง (อย่าใช้ค่าจาก env อื่น อย่าเอาลง repo):
openssl rand -base64 32
🔴 กุญแจที่ใช้ไปแล้วครั้งหนึ่ง ห้ามทำหาย — ข้อมูลที่เข้ารหัสด้วยกุญแจนั้นจะถอดไม่ได้อีกเลย บนเครื่อง dev ให้ตั้งเป็น user-secrets แล้วเก็บไว้ถาวร ไม่ใช่ generate ใหม่ทุกครั้งที่รัน
6. การหมุนกุญแจ (rotation) — โครงมีแล้ว ยังไม่เปิดใช้
เพราะทุกค่าพกหมายเลขกุญแจไปด้วย การหมุนจึงเป็น: เพิ่มกุญแจใหม่เข้าชุด → ชี้ CurrentKeyId ไปตัวใหม่ → ของเก่ายังถอดได้เรื่อย ๆ → ค่อยเขียนทับของเก่าเมื่อสะดวก ยังไม่มีใครทำจริง อย่าเพิ่งสร้าง automation รอบนี้
7. สิ่งที่การเข้ารหัสระดับคอลัมน์ ปิดไม่ได้
| ช่องโหว่ที่เหลือ | ทำไม | ทางแก้ที่ต้องออกแบบเพิ่ม |
|---|---|---|
| ข้อมูลอ่อนไหวที่อยู่ ข้างในคอลัมน์ JSON | cipher เห็นทั้งก้อนเป็นข้อความเดียว ไม่รู้ว่าข้างในมีเลขบัตร | ต้องเลือก: เข้ารหัสทั้งก้อน (เสียความสามารถในการ query ข้างใน) หรือเข้ารหัสรายฟิลด์ตอนเขียน/อ่าน |
| ข้อมูลอ่อนไหวที่หลุดไปอยู่ใน log | log เขียนก่อนถึงชั้นเข้ารหัส | ปิดที่จุดเขียน log (mask ก่อน log) ไม่ใช่ที่ชั้นข้อมูล |
| ข้อมูลใน backup เก่า | ถูกถ่ายไว้ก่อนเปิดการเข้ารหัส | เป็นเรื่องนโยบายเก็บ backup ไม่ใช่เรื่องโค้ด |
ในของจริงตอนนี้: เข้ารหัสจริงแล้ว 2 คอลัมน์ จากจุดที่จัดว่าอ่อนไหวราว 20 จุด และเลขบัตรประชาชนแบบดิบยังอยู่ในคอลัมน์ JSON ของ 3 ตาราง — นี่คืองานที่ยังเปิดอยู่ ไม่ใช่งานที่ปิดแล้ว
8. Acceptance test
[Fact]
public async Task SensitiveField_IsNotReadableInStorage()
{
await _repo.SaveAsync(new User { CardId = "1234567890123" });
var raw = await _db.QuerySingleAsync<string>("select \"CardId\" from \"Users\" limit 1");
raw.Should().NotContain("1234567890123"); // ต้องอ่านไม่ออกจากที่เก็บ
var readBack = await _repo.GetAsync(id);
readBack.CardId.Should().Be("1234567890123"); // แต่แอปต้องอ่านกลับมาได้ถูกต้อง
}
[Fact]
public void FlagOn_WithoutKeyMaterial_Throws_InsteadOfWritingPlaintext()
{
var act = () => BuildProviderWith(redisEnabled: true, keys: new Dictionary<string, string>());
act.Should().Throw<Exception>();
}
การพิสูจน์ที่หนักแน่นที่สุดคือ “ปิดโค้ดเข้ารหัสแล้ว test ต้องแดง” — ถ้าปิดแล้ว test ยังเขียว แปลว่า test ไม่ได้ตรวจสิ่งที่คิดว่าตรวจอยู่
9. Checklist
- ทำตารางจัดประเภทข้อมูลครบ 4 ช่อง ก่อน เขียนโค้ด
- ระบุว่าข้อมูลเดียวกันไปโผล่ที่ไหนบ้าง (DB / JSON / cache / log)
- เริ่มที่พื้นผิว cache ก่อน (ไม่ต้อง migrate)
- แยก slot DI ต่อพื้นผิว ไม่ให้สวิตช์ AND กันโดยไม่ตั้งใจ
- fail-closed + test ที่ยืนยันว่ามันโยน error จริง
- กุญแจอยู่ KeyVault ·
CurrentKeyIdและสวิตช์อยู่ IaC ครบทุก env - มีคำตอบสำหรับ field ที่ต้องใช้ค้นหา ก่อนเข้ารหัสมัน