Private Docs

Security Uplift · B — Payload field encryption

ติดตั้งการถอด/เข้ารหัสเฉพาะ field ที่ทำเครื่องหมายไว้ ทั้งขาเข้าและขาออก โดย handler ไม่ต้องแก้อะไรเลย

อัปเดต: 2026-09-07

📘 แนวคิดเบื้องหลังกลไกนี้ → Encryption Field 101 📕 สิ่งที่พังได้ในแต่ละขั้น → กับดัก B 📗 ภาพรวมทั้ง 6 กลไก · กฎ opt-in · การเปิดเป็นราย env → ติดตั้งใน service ของคุณ

B. Payload field encryption — เฉพาะ service ที่รับ field เข้ารหัสจาก FE

เข้ารหัสเฉพาะ field ที่ทำเครื่องหมายไว้ (เลขบัตร เบอร์โทร อีเมล) ทำได้ทั้ง 2 ทาง — ขาเข้า FE ส่งมา service ถอด · ขาออก service เข้ารหัสตอบกลับ FE ถอด · handler ไม่ต้องแก้อะไรเลย ทำงานกับ plaintext เหมือนเดิมทั้ง 2 ทาง

อยากเห็นภาพว่ามันทำงานยังไง → ของที่ทำเสร็จแล้ว ทำงานยังไง

ต้องรู้ก่อน 3 ข้อ

  1. ต้องมีกุญแจใน KeyVault ก่อนเปิด flag ไม่งั้นแอปไม่ boot — ดูหัวข้อ KeyVault
  2. B เป็นรายฟิลด์เท่านั้น — จะเข้ารหัสทั้ง body ต้องใช้ D (ติดตั้ง D) ซึ่งเป็นคนละ flag คนละ attribute · action เดียวใช้ทั้ง B และ D ไม่ได้ แปะทั้งคู่ = แอปไม่ boot
  3. ขาออกต้องให้ caller ลงทะเบียนกุญแจก่อน (ที่ Sentinel — ไม่ใช่งานของ service คุณ) ไม่ลงทะเบียน = ได้ plaintext ตามเดิม ไม่ error

ทั้งเส้นทำงานยังไง

sequenceDiagram
    autonumber
    participant FE as Browser (auth-sdk)
    participant SG as Sentinel Gateway
    participant KV as Key Vault
    participant IN as UsePayloadFieldDecryption
    participant H as Handler
    participant R as Redis

    Note over IN,KV: ตอน service สตาร์ท AddPayloadFieldDecryption อ่านกุญแจจาก config ที่ CSI mount มาจาก KV<br/>เปิด flag แต่ไม่มีกุญแจ = แอปไม่ boot
    FE->>SG: ขอ public key ของ service (JWKS)
    SG-->>FE: public key RSA พร้อม kid
    FE->>FE: encryptFields — เข้ารหัสเฉพาะ field ที่ตกลงไว้ด้วย RSA-OAEP-256 + A256GCM
    FE->>IN: POST body ที่ field นั้นเป็น JWE ส่วน field อื่นอ่านออกตามปกติ

    alt endpoint ไม่มี EncryptedFieldMap หรือ flag ปิด
        IN->>H: ผ่านไปเลย ไม่แตะ body
    else มี map และอยู่ใน Coverage
        IN->>IN: body เกิน MaxRequestBodySizeBytes — 413 PayloadTooLarge
        IN->>IN: ค่าที่ส่งมาเป็น plaintext — รับตามออกแบบ ไม่ error
        IN->>IN: ถอด JWE ไม่ออก — 400 PayloadDecryptionError ไม่กลืนเป็น text
        IN->>H: ใส่ค่าที่ถอดแล้วกลับที่เดิม handler เห็น plaintext
    end

    H->>H: ทำงานปกติ แล้ว return DTO ที่ประกาศไว้ใน ProducesResponseType
    H->>R: middleware ขาออกหา oid ของ caller แล้วถามกุญแจรับของที่ลงทะเบียนไว้
    alt caller ยังไม่ลงทะเบียน encryption key
        R-->>FE: ตอบ plaintext ตามเดิม ของเดิมไม่พัง
    else มีกุญแจ
        R-->>H: public key EC ของ caller
        H->>H: สร้างคู่กุญแจใช้แล้วทิ้ง ตกลงกุญแจด้วย ECDH-ES+A256KW แล้วปิดผนึกเฉพาะ field ที่ mark
        H-->>FE: response ที่ field นั้นเป็น JWE — กุญแจของ caller เท่านั้นที่เปิดได้
    end
    Note over H,FE: กุญแจของ caller ใช้ไม่ได้ (เสีย/รูปแบบผิด) ตอบ 500 PayloadEncryptionError<br/>ไม่ส่ง plaintext ออกไปและไม่ยัด null ให้ปนกับค่าว่างจริง

1. DI — บรรทัดเดียว

services.AddPayloadFieldDecryption(configuration);

ตัวนี้ทำ 3 อย่างให้ เมื่อ FieldEncryption:Payload:Enabled = true เท่านั้น: register ตัวถอดรหัส · อ่านกุญแจจาก config · ลงทะเบียน convention ที่ไปหา field ที่ต้องถอดให้เอง ไม่ต้องแตะ AddControllers

flag ปิด = บรรทัดนี้ไม่ register อะไรเลย ⇒ ปลอดภัยที่จะเรียกทิ้งไว้ตั้งแต่ยังไม่เปิดใช้

2. ทำเครื่องหมายบน DTO ว่า field ไหนเข้ารหัส

using SupApp_util_lib.Abstractions.Security;

public sealed record SubmitConsentRequest(
    string FirstName,
    string LastName,
    [property: EncryptedField] string Email);

DTO อยู่ layer Application ได้ (attribute อยู่ใน lib จึงอ้างได้จากทุก layer)

นี่คือที่เดียวที่ตัดสินว่า field ไหนเข้ารหัส — ไม่มีรายการ field ใน config ให้ตั้งซ้ำ

📕 กับดัก B-2[property: ...] บน positional record · property ต้องอยู่ระดับบนสุดของ payload

3. สวิตช์ตัวเดียวของทั้ง service

"FieldEncryption": {
  "Payload": {
    // false = ไม่ถอดอะไรเลย body ไหลเข้า handler ตามเดิม (ค่าตั้งต้น)
    // true  = ถอดทุก field ที่ทำเครื่องหมายไว้ · ต้องมีกุญแจใน KV แล้ว
    "Enabled": false
  }
}

ค่า false ตั้งต้นวางใน base appsettings.json (ไม่ถูก overlay ทับ จึงปิดทุก env และไม่ติด parity) · ตอนจะเปิด env ไหน ค่อยใส่ true ที่ appsettings.{ENV}.json + IaC config ของ env นั้นคู่กัน — กติกาเดียวกันทุกกลไก ดูหัวข้อเปิดเป็นราย env

📕 กับดัก B-3 — ทำไมต้องมี flag ในเมื่อ attribute บอกอยู่แล้ว

4. pipeline

app.UseClientSignatureVerification();
app.UsePayloadFieldDecryption();        // ต้องต่อท้ายทันที ห้ามสลับ

บรรทัดนี้ mount ทั้งขาเข้าและขาออก ไม่มีบรรทัดเพิ่มสำหรับขาออก

5. ขาออก — ทำเครื่องหมายบน response DTO

public sealed record GetUserProfileResponse(
    string FirstNameTH,
    string LastNameTH,
    [property: EncryptedField] string Phone);

attribute ตัวเดียวกับขาเข้า ตำแหน่งบอกทิศ — อยู่บน request DTO = “เข้ามาเข้ารหัส ให้ถอด” · อยู่บน response DTO = “ออกไปเข้ารหัส”

handler ไม่ต้องแก้อะไรเลย ยัง return ค่าปกติ middleware เข้ารหัสให้ตอนขาออก

action ต้องประกาศชนิดของ 2xx ด้วย ไม่ใช่แค่รหัส

[HttpGet("{id:guid}")]
[ProducesResponseType<CmsContentConfigDto>(StatusCodes.Status200OK)]   // มี <T>
// [ProducesResponseType(StatusCodes.Status200OK)]                     // ❌ แบบนี้ใช้ไม่ได้
public async Task<IActionResult> GetById(Guid id, CancellationToken ct = default)

ตัวที่ไปหาว่า field ไหนต้องเข้ารหัสตอนขาออก อ่านจากการประกาศ 2xx ของ action ที่เดียว

🔴 property ที่ mark ต้องอยู่ระดับบนสุดของ T ที่อยู่ใต้ data ไม่ใช่บน wrapper และไม่ใช่ซ้อนลึก

  • ประกาศเป็น ApiResponse<T> (ProducesApiResponse<T>) ได้ — convention แกะ wrapper ให้เอง โดยดูว่าชื่อ type ขึ้นต้นด้วย ApiResponse แล้วหยิบ generic argument ตัวแรกมาเป็น payload · ไม่ใช่เรื่องการสืบทอด wrapper ชื่ออื่นที่ไม่ได้ขึ้นต้นด้วย ApiResponse จะไม่ถูกแกะ และ mark ที่อยู่ใน T ของมันจะหาไม่เจอ
  • ตอน runtime ตัวเข้ารหัสมองหา key data ที่ระดับบนสุดของ response แล้วทำงานกับ object ข้างใน (ไม่มี data ⇒ ทำงานกับ root) ⇒ ชื่อ field ที่ mark ต้องเป็น property ระดับบนสุดของก้อนที่อยู่ใต้ data พอดี
  • mark ที่ซ้อนลึกกว่านั้น (อยู่ใน property ที่เป็น object ลูก) แอปไม่ boot — ตั้งใจให้พังตอน startup แทนที่จะปล่อยค่าออกไปเป็น plaintext เงียบ ๆ

caller ต้องลงทะเบียนกุญแจ EC ดอกที่สองที่ Sentinel — ไม่ใช่ที่ service คุณ ไม่ต้องมี endpoint ไม่ต้องเขียน handler

POST {gateway}/{env}-sentinel-gateway-api/sentinel/client-keys
{
  "publicKeyJwk":           { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." },
  "encryptionPublicKeyJwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }   // เพิ่มตัวนี้
}

ฝั่ง FE @exim/auth-sdk แนบให้เองตั้งแต่ 1.9.0

📕 กับดัก B-5 — ประกาศ 2xx ไม่ครบแล้วเงียบ · ต้องมี AddClientSignatureStores() ก่อน · สิ่งที่ต้องบอก FE · เรื่องกุญแจของ caller

เช็คว่าขาออกทำงาน

เช็คผลที่ถูก
caller ลงทะเบียนกุญแจเข้ารหัสแล้วfield ที่ mark เป็น JWE 5 ส่วน · ถอดด้วย private key ของตัวเองได้ค่าเดิม
field ที่ mark เป็น object/arrayเข้ารหัสทั้งก้อน · ถอดแล้ว JSON.parse กลับมาเป็น object รูปเดิม
caller ลงทะเบียนแต่ signing keyได้ plaintext ตามเดิม
response ที่เป็น errorไม่ถูกแตะ
caller ที่ไม่มี identity (s2s)ไม่ถูกแตะ
กุญแจของ caller ใช้ไม่ได้ (เสีย/รูปแบบผิด)500 PayloadEncryptionError — ไม่ส่ง plaintext ออกไป และไม่ยัด null ให้ปนกับค่าว่างจริง

เช็คว่าทำงาน

เช็คผลที่ถูก
flag ปิด · ส่ง plaintext2xx เหมือนเดิม (caller เดิมไม่พัง)
flag เปิด · ส่ง plaintext2xx เหมือนกัน — dual-accept ระหว่างที่ FE ยังทยอยเปลี่ยน
flag เปิด · ส่ง JWE2xx และค่าใน DB เป็น plaintext ที่ถูกต้อง
flag เปิด · ส่ง ciphertext เสีย400 PayloadDecryptionError (ไม่ใช่ 500)
เปิด flag แต่ไม่มีกุญแจใน KVแอปไม่ boot — ถูกต้องแล้ว

ไม่กระทบ performance จนกว่าจะเปิด flag และเมื่อเปิดแล้ว จ่ายเฉพาะ request ที่ยิงเข้า endpoint ที่มี field ทำเครื่องหมายไว้

📕 กับดัก B — performance และเพดาน — เพดาน 16KB ต่อ field · ขาออกแพงกว่าขาเข้า · ตารางต้นทุนต่อ request · การอัปเกรดจากเวอร์ชันก่อน 10.18.0