Private Docs

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. เปิดทีละพื้นผิว — แยกสวิตช์ ไม่ใช่สวิตช์เดียว

ข้อมูลชุดเดียวกันไปโผล่หลายที่ ⇒ ต้องเปิดทีละที่ และแต่ละที่ต้องมีสวิตช์ของตัวเอง

พื้นผิวสวิตช์หมายเหตุ
คอลัมน์ใน DBFieldEncryption:AtRest:Enabledต้องคิดเรื่อง migration ของข้อมูลเดิม
ค่าที่เขียนลง Redis cacheFieldEncryption: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:Enabledappsettings + IaC ทุก envไม่ใช่ความลับ
FieldEncryption:CurrentKeyIdappsettings + IaC ทุก envเป็นแค่ “ชื่อ” ของกุญแจ ไม่ใช่ตัวกุญแจ
FieldEncryption:Keys:{keyId}🔴 KeyVault เท่านั้นตัวกุญแจจริง (base64 32 ไบต์)

สร้างกุญแจสำหรับเครื่องตัวเอง (อย่าใช้ค่าจาก env อื่น อย่าเอาลง repo):

openssl rand -base64 32

🔴 กุญแจที่ใช้ไปแล้วครั้งหนึ่ง ห้ามทำหาย — ข้อมูลที่เข้ารหัสด้วยกุญแจนั้นจะถอดไม่ได้อีกเลย บนเครื่อง dev ให้ตั้งเป็น user-secrets แล้วเก็บไว้ถาวร ไม่ใช่ generate ใหม่ทุกครั้งที่รัน

6. การหมุนกุญแจ (rotation) — โครงมีแล้ว ยังไม่เปิดใช้

เพราะทุกค่าพกหมายเลขกุญแจไปด้วย การหมุนจึงเป็น: เพิ่มกุญแจใหม่เข้าชุด → ชี้ CurrentKeyId ไปตัวใหม่ → ของเก่ายังถอดได้เรื่อย ๆ → ค่อยเขียนทับของเก่าเมื่อสะดวก ยังไม่มีใครทำจริง อย่าเพิ่งสร้าง automation รอบนี้

7. สิ่งที่การเข้ารหัสระดับคอลัมน์ ปิดไม่ได้

ช่องโหว่ที่เหลือทำไมทางแก้ที่ต้องออกแบบเพิ่ม
ข้อมูลอ่อนไหวที่อยู่ ข้างในคอลัมน์ JSONcipher เห็นทั้งก้อนเป็นข้อความเดียว ไม่รู้ว่าข้างในมีเลขบัตรต้องเลือก: เข้ารหัสทั้งก้อน (เสียความสามารถในการ query ข้างใน) หรือเข้ารหัสรายฟิลด์ตอนเขียน/อ่าน
ข้อมูลอ่อนไหวที่หลุดไปอยู่ใน loglog เขียนก่อนถึงชั้นเข้ารหัสปิดที่จุดเขียน 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 ที่ต้องใช้ค้นหา ก่อนเข้ารหัสมัน