Private Docs

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 — เข้ารหัสข้อมูลที่เก็บไว้

🔴 ใช้ได้เฉพาะกับคอลัมน์ที่ผ่าน EFApplyAtRestEncryption(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

หมุนกุญแจ

  1. เพิ่ม secret AtRestEncryption--Keys--{ชื่อใหม่} ใน Key Vault (ไม่ลบดอกเก่า)
  2. บน cluster: เพิ่มชื่อดอกใหม่ใน SecretProviderClass ของ service นั้น
  3. ชี้ AtRestEncryption:CurrentKeyId ไปดอกใหม่
  4. 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: