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 ข้อ
- ต้องมีกุญแจใน KeyVault ก่อนเปิด flag ไม่งั้นแอปไม่ boot — ดูหัวข้อ KeyVault
- B เป็นรายฟิลด์เท่านั้น — จะเข้ารหัสทั้ง body ต้องใช้ D (ติดตั้ง D) ซึ่งเป็นคนละ flag คนละ attribute · action เดียวใช้ทั้ง B และ D ไม่ได้ แปะทั้งคู่ = แอปไม่ boot
- ขาออกต้องให้ 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 ปิด · ส่ง plaintext | 2xx เหมือนเดิม (caller เดิมไม่พัง) |
| flag เปิด · ส่ง plaintext | 2xx เหมือนกัน — dual-accept ระหว่างที่ FE ยังทยอยเปลี่ยน |
| flag เปิด · ส่ง JWE | 2xx และค่าใน DB เป็น plaintext ที่ถูกต้อง |
| flag เปิด · ส่ง ciphertext เสีย | 400 PayloadDecryptionError (ไม่ใช่ 500) |
| เปิด flag แต่ไม่มีกุญแจใน KV | แอปไม่ boot — ถูกต้องแล้ว |
ไม่กระทบ performance จนกว่าจะเปิด flag และเมื่อเปิดแล้ว จ่ายเฉพาะ request ที่ยิงเข้า endpoint ที่มี field ทำเครื่องหมายไว้
📕 กับดัก B — performance และเพดาน — เพดาน 16KB ต่อ field · ขาออกแพงกว่าขาเข้า · ตารางต้นทุนต่อ request · การอัปเกรดจากเวอร์ชันก่อน 10.18.0