Security Uplift · C — At-rest encryption
ติดตั้งการเข้ารหัสคอลัมน์ใน DB ผ่าน EF: แปะ [EncryptedAtRest] ผูกเข้ากับ model เปิดสวิตช์ เลือกคอลัมน์ และหมุนกุญแจ
อัปเดต: 2026-09-07
📘 แนวคิดเบื้องหลังกลไกนี้ → Data Encryption & Masking 101 📕 สิ่งที่พังได้ในแต่ละขั้น → กับดัก C 📗 ภาพรวมทั้ง 6 กลไก · กฎ opt-in · การเปิดเป็นราย env → ติดตั้งใน service ของคุณ
C. At-rest Encryption — เข้ารหัสข้อมูลที่เก็บไว้
🔴 ใช้ได้เฉพาะกับคอลัมน์ที่ผ่าน EF — ApplyAtRestEncryption(modelBuilder) ทำงานผ่าน value converter ของ EF ⇒ service ที่ไม่มี DbContext ใช้กลไกนี้ไม่ได้ · ค่าที่ไม่ได้ผ่าน EF (Redis, blob, ไฟล์, raw SQL) ไม่ถูกเข้ารหัสแม้ property จะแปะ [EncryptedAtRest] ไว้
ทั้งเส้นทำงานยังไง
sequenceDiagram
autonumber
participant KV as Key Vault
participant S as Service ตอน startup
participant H as Handler
participant EF as EF value converter
participant DB as PostgreSQL
S->>KV: AddAtRestEncryption อ่านพวงกุญแจ AtRestEncryption Keys
Note over S: ไม่มีกุญแจแต่เปิดสวิตช์ = แอปไม่ boot · ตั้ง CurrentKeyId ไว้แต่ไม่มี Keys = ไม่ boot แม้สวิตช์ปิด
S->>S: ApplyAtRestEncryption สแกนทุก entity หา EncryptedAtRest แล้วต่อ converter ให้
Note over S: ปฏิเสธตั้งแต่ boot ถ้า property ไม่ใช่ string เป็น key หรืออยู่ใน index
H->>EF: SaveChanges — เขียนค่าปกติ
alt EncryptNewWrites = true
EF->>EF: AES-256-GCM ด้วยกุญแจปัจจุบัน
EF->>DB: เก็บเป็น v1 ตามด้วยชื่อกุญแจ ตามด้วย base64
else false
EF->>DB: เก็บ plaintext ตามเดิม
end
DB-->>EF: อ่านค่ากลับ
alt ค่าขึ้นต้นด้วย v1
EF->>EF: หยิบชื่อกุญแจจากตัวค่าเอง แล้วถอดด้วยกุญแจดอกนั้น
else ไม่ขึ้นต้น (แถวเก่าที่ยังไม่ถูกเขียนทับ)
EF->>EF: ส่งต่อตามเดิม ไม่แตะ
end
EF-->>H: handler เห็นค่าปกติทั้งขาเขียนและขาอ่าน
Note over EF,DB: ฝั่งอ่านไม่มีสวิตช์ — พยายามถอดเสมอ ⇒ ปิดสวิตช์กลับแล้วแถวที่เข้ารหัสไปแล้วยังอ่านออก
ติดตั้ง 3 ขั้น
1. แปะบน property ที่อ่อนไหว
public class Customer
{
public Guid Id { get; set; }
[EncryptedAtRest] public string Email { get; set; } = string.Empty; // เข้ารหัสในคอลัมน์นี้
public string DisplayName { get; set; } = string.Empty; // ไม่แตะ
}
คนละ attribute กับ B โดยตั้งใจ — [EncryptedField] = เข้ารหัสบนสายระหว่าง browser กับ service · [EncryptedAtRest] = เข้ารหัสในฐานข้อมูล · คนละกุญแจ คนละอายุ กันคนละคน · property เดียวแปะทั้งสองตัวได้ถ้าต้องการทั้งสองชั้น
📕 กับดัก C-1 — เงื่อนไขที่ทำให้กลไกนี้ได้ผลจริง · แปะ attribute ผิดตัวแล้วเป็นยังไง
2. DI + ผูกเข้ากับ model
// Program.cs
builder.Services.AddAtRestEncryption(builder.Configuration);
// DbContext
private readonly IFieldCipher? _fieldCipher;
// ต้อง optional เพราะ design-time factory สร้าง context โดยไม่มี container
public MyDbContext(DbContextOptions<MyDbContext> options, IFieldCipher? fieldCipher = null)
: base(options) => _fieldCipher = fieldCipher;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MyDbContext).Assembly);
modelBuilder.ApplyAtRestEncryption(_fieldCipher); // ← หลังการตั้ง entity/index ทั้งหมด
}
บรรทัดเดียวหา [EncryptedAtRest] เองทั้ง model — ไม่ต้องเขียน HasConversion ทีละ property ไม่ต้องรู้ชื่อคอลัมน์
📕 กับดัก C-2 — ทำไมต้องอยู่หลัง key/index · ทำไมห้ามใส่
ifล้อม
3. สวิตช์
"AtRestEncryption": {
// คุมแค่ "ตอนเขียน" — false = เขียนใหม่ยังเป็น plaintext (ค่าตั้งต้น)
// true = เขียนใหม่เป็น ciphertext
"EncryptNewWrites": false
}
กุญแจไม่ต้องประกาศที่นี่ มาจาก Key Vault
attribute กับ flag ตอบคนละคำถาม — [EncryptedAtRest] บอกว่า คอลัมน์ไหน · EncryptNewWrites บอกว่า เริ่มเขียนเมื่อไหร่ ⇒ deploy โค้ดที่แปะ attribute ไปก่อนโดยยังไม่เขียน ciphertext ได้ แล้วค่อยเปิด flag ทีหลัง
🔴 flag เดียวคุมทุกคอลัมน์ที่แปะ — เปิดเมื่อไหร่ ทุก property ที่มี [EncryptedAtRest] เริ่มเข้ารหัสพร้อมกัน ⇒ คอลัมน์ที่ยังไม่ต้องการเข้ารหัส อย่าแปะ
🔴 เปิดแล้วย้อนไม่สะอาด — ปิด flag กลับ แถวที่เขียนเป็น ciphertext ไปแล้วยังเป็น ciphertext (อ่านออกเพราะฝั่งอ่านพยายามถอดเสมอ) ⇒ การเปิดบน env จริงถือเป็นการตัดสินใจถาวร
📕 กับดัก C-2 — คอลัมน์
jsonbยังไม่รองรับ · การเรียกAddAtRestEncryptionหลายรอบเพื่อแยก surface
ฝั่งอ่านอยู่ไหน — ไม่มีสวิตช์ เพราะการอ่านไม่ใช่ทางเลือก
ค่าที่เก็บไว้บอกตัวเองว่าเข้ารหัสอยู่หรือเปล่า
v1:kvdev20260830:Tqi/RXqumWNDs57h4exo... ← ขึ้นต้นด้วย v1: → ถอด
system ← ไม่ขึ้นต้น → ส่งต่อตามเดิม
ตัดสินรายแถว จากตัวข้อมูลเอง ไม่ใช่จาก config ⇒ เปิดสวิตช์ได้โดยไม่ต้องแปลงข้อมูลเก่าก่อน และปิดกลับได้โดยแถวที่เข้ารหัสไปแล้วยังอ่านออก
กุญแจ — Keys กับ CurrentKeyId
Keys= ทุกดอกที่เคยใช้ เก็บไว้เพื่ออ่านของเก่าCurrentKeyId= ดอกที่ใช้ล็อกของใหม่ตั้งแต่วันนี้
วันแรกไม่ต้องตั้ง CurrentKeyId — มีกุญแจดอกเดียวก็ไม่มีอะไรให้ชี้
ตัวกุญแจมาจาก Key Vault เป็น secret ที่ไม่มี prefix ชื่อ service ⇒ ทุก service อ่านตัวเดียวกัน:
AtRestEncryption--Keys--kvdev20260830 ← ค่ากุญแจ base64 32 ไบต์
AtRestEncryption--Keys--localdev1 ← ดอกที่สองจะมีก็ต่อเมื่อหมุนกุญแจแล้วเท่านั้น
CurrentKeyId ไม่ใช่ secret — มันคือชื่อ ไม่ใช่ตัวกุญแจ และต้องต่างกันตาม env ⇒ วางที่ appsettings.{ENV}.json + IaC
หมุนกุญแจ
- เพิ่ม secret
AtRestEncryption--Keys--{ชื่อใหม่}ใน Key Vault (ไม่ลบดอกเก่า) - บน cluster: เพิ่มชื่อดอกใหม่ใน SecretProviderClass ของ service นั้น
- ชี้
AtRestEncryption:CurrentKeyIdไปดอกใหม่ - restart pod
แถวใหม่ล็อกด้วยดอกใหม่ · แถวเก่ายังอ่านได้ตลอดไป เพราะมันพกชื่อดอกของตัวเองอยู่ · ไม่ต้อง re-encrypt ทั้งตาราง
📕 กับดัก C — กุญแจและการหมุนกุญแจ — ห้ามลบกุญแจเก่า ·
CurrentKeyIdค้างโดยไม่มีKeys· service รู้ได้ยังไงว่าต้องดึงพวงไหน · การตั้งชื่อกุญแจบน prod
เลือกคอลัมน์
ต้องเป็น string · ห้ามเป็น key หรืออยู่ใน index · คอลัมน์ต้องกว้างพอ
2 ข้อแรกตัวโปรแกรมกันให้แล้ว ข้อ 3 ต้องเช็คเอง — ค่าที่เก็บยาวขึ้นประมาณ 2.5–3 เท่า
📕 กับดัก C — เลือกคอลัมน์ — สูตรคำนวณความยาว · ตัวอย่างจริง · string ว่าง · ข้อมูลเก่าที่ยังไม่ถูกเขียนทับ
เรื่อง performance
วัด AES-256-GCM บนค่ายาว 28 ตัว: เข้ารหัส ~1.7 ไมโครวินาที · ถอด ~1.3 ไมโครวินาที ต่อค่า ⇒ request ที่อ่าน 100 แถว แถวละ 1 field ≈ 0.15 มิลลิวินาที เทียบกับ round trip ไป DB ครั้งเดียว 1–5 มิลลิวินาที
ต้นทุนจริงไม่ใช่ CPU แต่คือพื้นที่ (~3 เท่า ทั้ง DB และ backup) และการที่คอลัมน์นั้น query ไม่ได้อีก
เช็คว่าทำงาน
| เช็ค | ผลที่ถูก |
|---|---|
| สวิตช์ปิด · อ่าน/เขียนแถวเดิม | เหมือนเดิมทุกอย่าง |
| สวิตช์เปิด · เขียนแถวใหม่ | คอลัมน์ใน DB เป็น v1:{ชื่อกุญแจ}:{base64} · อ่านผ่าน API ได้ค่าปกติ |
| สวิตช์เปิด · อ่านแถวเก่าที่ยังไม่เข้ารหัส | อ่านได้ปกติ |
| ปิดสวิตช์กลับ · อ่านแถวที่เข้ารหัสไปแล้ว | ยังอ่านได้ |
| แปะ attribute บน property ที่ไม่ใช่ string / เป็น key / อยู่ใน index | พังทันทีที่ DbContext ถูกใช้ครั้งแรก พร้อมบอกว่า property ไหน |
แปะ [EncryptedField] (ของ B) บน entity แทน [EncryptedAtRest] | พังทันทีที่ DbContext ถูกใช้ครั้งแรก พร้อมบอกชื่อ property + ตัวที่ควรใช้ |
| เปิดสวิตช์แต่ไม่มีกุญแจใน KV | แอปไม่ boot |
ตั้ง CurrentKeyId ไว้แต่ไม่มี Keys | แอปไม่ boot แม้สวิตช์ปิด |
| เก็บ string ว่าง | ไม่ถูกเข้ารหัส เก็บว่างตามเดิม ไม่มี v1: |