Security Uplift — กับดักและคำเตือน
คู่มือของหน้าติดตั้ง: ทุกจุดที่พังได้ ทำไมถึงพัง และรู้ได้ยังไงว่ากำลังพังอยู่ · เรียงตาม section เดียวกับหน้าติดตั้ง เปิดคู่กันตอนลงมือจริง
อัปเดต: 2026-09-02
หน้านี้ไม่มีขั้นตอนติดตั้ง — ขั้นตอนอยู่ที่ ติดตั้งใน service ของคุณ หน้านี้เก็บสิ่งที่พังได้ในแต่ละขั้นไว้แยก เพื่อให้หน้าติดตั้งอ่านรวดเดียวจบตอน review และเปิดคู่กันตอนลงมือจริง
หมายเลขหัวข้อตรงกับหน้าติดตั้งทุกตัว
A-1 · package
เวอร์ชันต่ำกว่า 10.23.0 มีปัญหาที่หน้าติดตั้งถือว่าปิดไปแล้ว
- ก่อน 10.22.0 ไม่มี
[EncryptedAtRest]⇒ กลไก C compile ไม่ผ่าน - ก่อน 10.23.0 มีทางพังเงียบ 3 จุด: field ที่แปะไว้ซ้อนลึกหลุดเป็น plaintext · เข้ารหัสขาออกพลาดแล้วได้
nullใน 200 · error ของ at-rest ชี้ section ที่ service ไม่ได้ใช้
net9 ขึ้นไป — middleware/stores/attribute อยู่หลัง #if NET9_0_OR_GREATER (options กับ verifier เป็น net6 แต่ท่าติดตั้งในหน้านั้นใช้ของ net9) และต้องมี IConnectionMultiplexer ใน DI อยู่แล้ว
restore ไม่ผ่าน 401 ที่ feed
export FEED_TOKEN=$(az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv)
A-3 · pipeline
🔴 EnableBuffering() ต้องมีอยู่ก่อน middleware นี้ — lib ไม่ได้เปิดให้เอง และ middleware ปฏิเสธ request ที่ body อ่านซ้ำไม่ได้ด้วย SIG_BODY_UNREADABLE ⇒ service ที่ไม่มีบรรทัดนี้จะ 401 ทุก request ที่เซ็น
grep -rn "EnableBuffering" src/{Service}01.API/
ไม่เจอให้เติมเองก่อน UseClientSignatureVerification():
app.Use(async (context, next) => { context.Request.EnableBuffering(); await next(); });
2 service ที่ทำไปแล้วมีบรรทัดนี้ค้างจากยุค
SignatureValidationเดิม เลยไม่เคยเจอ — service ใหม่ไม่มี
🔴 ลายเซ็นต้องมาก่อนถอดรหัส สลับไม่ได้ — ลายเซ็นครอบ ciphertext ตามที่มาบนสาย ถอดรหัสก่อน = ลายเซ็นพังทุกอัน
เกณฑ์ตำแหน่งที่แท้จริงคือ “หลัง routing + UseAuthentication()” ไม่ใช่หลัง UseRedisUserInfo() — middleware อ่าน oid จาก identity ของ token ตรง ๆ และจงใจไม่ใช้ค่าที่ UseRedisUserInfo() เขียนทับ (NameIdentifier ที่ถูกแทนด้วย internal user id) ⇒ จะวางก่อนหรือหลังตัวนั้นก็ได้ · ไปดูลำดับจริงในไฟล์ตัวเองก่อนเสมอ อย่าก๊อปบล็อกในหน้าติดตั้งแบบไม่ดู
A-4 · appsettings
🔴 Enabled กับ Mode อ่านครั้งเดียวตอน startup — เปลี่ยนค่าแล้วต้อง restart ไม่ใช่ toggle สด
🔴 KeyRegistryPrefix ผิดตัวเดียว = ทุก request 401 — กุญแจที่ Sentinel เขียนไว้จะอยู่คนละที่กับที่ service ไปหา
DebugResponse เปิดได้เฉพาะตอน debug บนเครื่องตัวเอง — โค้ดบังคับปิดใน Production อยู่แล้วไม่ว่าตั้งค่าอะไร และแนบรายละเอียดเฉพาะเคส SIG_INVALID
⚠️ เพิ่ม key ใน appsettings.{ENV}.json แล้วต้องไปเพิ่มที่ IaC ด้วย ไม่งั้น deploy พัง
ตอน deploy CI จะ cp ทับทั้งไฟล์จาก Backend_Iac/config/{service}/{env}/appsettings.json (full replace ไม่ merge) และมี guard check-config-parity.py ที่ทำ build FAIL ถ้า IaC ขาด key ที่ service มี
เพิ่ม key เดียวกันให้ครบทุก env ใน pass เดียวกัน — base appsettings.json ไม่ถูกทับ จึงไม่ติด parity
รอบที่ผ่านมาเจอมาแล้ว: 2 key ไม่ได้เข้า IaC → parity ตกทั้ง dev/sit/uat ทั้งที่โค้ดไม่มีปัญหาเลย
A-5 · [SkipClientSignature]
🔴 ไม่สืบทอดลง controller ลูก (Inherited = false) — ติดบน base controller แล้วคิดว่าครอบทั้งหมด = ไม่ครอบ ต้องติดที่ controller จริงทุกตัว
🔴 endpoint ที่ไม่มีทั้ง [Authorize] และ [AllowAnonymous] คือตัวที่ทำให้พังจริง — ดูปกติดีตอน flag ปิด แต่พอเปิด Enforce มันไม่มี identity ไปเซ็น = 401 ทันที
ยืนยันบน service จริงแล้ว — POST /document-numbers/generate ของ Centralized ไม่มีทั้งสอง attribute เปิด Enforce แล้วยิงโดยไม่เซ็น ได้
{"type":"ClientSignatureValidationError","title":"Missing Signature",
"status":401,"code":"SIG_MISSING_HEADER"}
ถ้าตัดสินเองไม่ได้ ให้ทำแบบนี้ — ห้ามเดา
การเปลี่ยน endpoint จาก “เรียกได้โดยไม่ login” เป็น “ต้อง login” เป็น การเปลี่ยนพฤติกรรมที่กระทบ caller ไม่ใช่สิ่งที่ dev ตัดสินคนเดียวได้ในขั้นนี้ ทุก service มีเส้นแบบนี้ค้างอยู่ไม่มากก็น้อย
- ทำ inventory ก่อน — ไล่ทุก action แยกเป็น 3 กองตามตารางในหน้าติดตั้ง ยังไม่ต้องแก้อะไร
- หา caller จริงของกองที่ 3 — grep ชื่อ endpoint ในรีโปอื่น (service อื่น / FE) · caller เป็น service = เข้าเกณฑ์
[SkipClientSignature]· เรียกจากหน้าที่ login แล้ว = เข้าเกณฑ์[Authorize] - ที่ยังหาไม่เจอ ส่ง list ให้ Owner ตัดสิน พร้อมบอกผลกระทบ — อย่าใส่
[Authorize]เอาเอง เดาผิด = caller ที่ทำงานอยู่พังเงียบ - ระหว่างรอ ห้ามเปิด
Enforceบน env นั้น —LogOnlyปลอดภัย ใช้ดูว่าเส้นไหนจะโดนได้เลย
A · ตอนเช็ค
service ที่ config ในรีโปว่าง ต้องต่อ KeyVault ก่อนถึงจะ boot ได้
หลาย service มี appsettings.Development.json ที่ ConnectionStrings, Redis:HostName, KeyVault:VaultUri เป็นค่าว่าง เพราะค่าจริงมาจาก IaC overlay ตอน deploy ⇒ รัน local ตรง ๆ จะ crash ตั้งแต่ startup ก่อนถึง middleware ด้วยซ้ำ (เช่น Encryption key cannot be null or empty)
ไม่ใช่ผลของ security uplift แต่ต้องข้ามให้ได้ก่อน
az login # ครั้งเดียวต่อเครื่อง
ASPNETCORE_ENVIRONMENT=Development KeyVault__VaultUri="https://sua-azure-nonprd-kv.vault.azure.net/" dotnet run
KeyVault:ServicePrefix ในรีโปเป็นตัวบอกว่าจะดึง secret ชุดไหน (dev--{service}) — ถ้ามีอยู่แล้วไม่ต้องแตะ
ยิงไม่เซ็นตอน Enforce ได้ SIG_MISSING_HEADER ไม่ใช่ SIG_INVALID — middleware เช็ค header ก่อนถึงจะตรวจลายเซ็น เห็นรหัสนี้แปลว่าถูกแล้ว
request ที่เซ็นจริงเอามาจากไหน — Postman collection ที่เซ็นเองได้อยู่ที่ Atlas/docs/security/security-uplift-meeting-plan/20260831/postman/
B-2 · ทำเครื่องหมายบน DTO
🔴 บน positional record ต้องใช้ [property: ...] — ใส่ [EncryptedField] เฉย ๆ compile ไม่ผ่าน (CS0592 เพราะ attribute อนุญาตเฉพาะ property) ⇒ ข้อนี้ compiler จับให้ ไม่ใช่กับดักเงียบ
🔴 เข้ารหัสได้เฉพาะ property ที่อยู่ระดับบนสุดของ payload (หรือใต้ data) ทั้งขาเข้าและขาออก — แปะบน DTO ที่ซ้อนลึกกว่านั้น (เช่น data.customer.email) แอปไม่ boot พร้อมบอกว่า property ไหนอยู่ใต้ตัวไหน ⇒ ต้องยก property ขึ้นมาระดับบนของ payload
ก่อน 10.23.0 กรณีนี้ไม่ error — ค่าหลุดออกไปเป็น plaintext เงียบ ๆ
B-3 · สวิตช์
ทำไมต้องมี flag ในเมื่อ attribute บอกอยู่แล้ว
attribute บอกว่า field ไหนเข้ารหัสได้ · flag บอกว่า ตอนนี้เปิดใช้หรือยัง สองอย่างนี้เปลี่ยนคนละจังหวะ
- deploy โค้ดก่อน เปิดทีหลังได้ — ใส่ attribute ขึ้น production ได้โดยยังไม่เปิด ไม่มีอะไรเปลี่ยนสำหรับ caller
- เป็น kill switch — มีปัญหาแล้วปิดกลับด้วย config ไม่ต้อง revert โค้ด (แต่ต้อง restart)
- เปิดโดยกุญแจยังไม่ถึง KV ของ env นั้น = แอปไม่ boot flag ปิดคือสิ่งที่ทำให้ deploy โค้ดก่อนแล้วค่อยเตรียมกุญแจได้
B-5 · ขาออก
🔴 ถ้าประกาศ 2xx แต่ไม่ระบุชนิด จะ mark DTO ไว้เท่าไหร่ก็ไม่มีอะไรถูกเข้ารหัส · build ผ่าน · ไม่มี error · ไม่มี log
เจอจริงมาแล้ว — มี service ที่ทั้งรีโปไม่มี action ไหนประกาศชนิดของ 2xx เลยสักตัว mark DTO แล้วยิงจริง ได้ plaintext กลับมาเงียบ ๆ · เช็คก่อนเริ่ม:
grep -rn "ProducesResponseType<\|ProducesApiResponse<" src/
ไม่มีเลย = ต้องเติมเองที่ action ที่จะเข้ารหัส
🔴 ต้องมี AddClientSignatureStores() ของกลไก A ก่อน — ขาออกหยิบกุญแจของผู้รับจาก store เดียวกับที่ A ใช้ · ไม่มี = ไม่เข้ารหัสอะไรเลยแม้เปิด flag แล้ว เงียบ ๆ
ทำงานเฉพาะ response ที่เป็น application/json และเป็น 2xx — error body ไม่ถูกแตะ เพราะ client ต้องอ่านได้ว่าพลาดเพราะอะไร
สิ่งที่ต้องบอก FE: ค่าที่ได้กลับมาเป็น JWE ที่ต้องถอดแล้ว JSON.parse ต่อ (ค่าเดินทางเป็นรูป JSON ไม่ใช่ค่าเปล่า) · ฝั่ง FE ใช้ decryptResponseFields จาก @exim/auth-sdk/payload-encryption
สูตรที่ใช้จริง และทำไมขาออกไม่เหมือนขาเข้า
- ขาเข้าห่อกุญแจด้วย
RSA-OAEP-256(กุญแจของ service เอง อยู่ KV) · ขาออกเป็นECDH-ES+A256KW+A256GCMตกลงกุญแจกับ EC key ของ browser (header มีepkไม่มีkid) - สูตรถูกบังคับด้วยชนิดกุญแจของฝั่งที่เปิด — ของ service เป็น RSA ของ browser เป็น EC ใช้วิธีล็อกร่วมกันไม่ได้
- ขาออกไม่ต้องมี key material ของ service เลย กุญแจผู้รับมาจาก Redis และ ephemeral ถูกทิ้งทุก response
- field ที่เป็น object/array เข้ารหัสได้เหมือน scalar
คำอธิบายเต็มพร้อมภาพลำดับ → ของที่ทำเสร็จแล้ว ทำงานยังไง
เรื่องกุญแจของ caller
- เป็นกุญแจคนละดอกกับที่ใช้เซ็น ถึงจะเป็น P-256 เหมือนกัน — กุญแจเซ็น WebCrypto ไม่ยอมให้เอาไป derive ⇒ ใช้ดอกเดิมไม่ได้จริง ๆ
- caller ที่ยังไม่ส่ง = ได้ plaintext ไม่ error ไม่พัง (FE เก่า, service อื่นที่เรียกแบบ s2s)
- ส่งเพิ่มทีหลังได้ — signing key เดิมตรงกัน ระบบถือว่าเป็นการอัปเกรด ไม่ใช่ 409
- ทั้งสองดอกไปในใบเดียวกันเสมอ — Sentinel ยืนยันช่องของผู้ใช้จากกุญแจเซ็น จะส่งกุญแจรับของมาลอย ๆ ไม่ได้
B · performance และเพดาน
🔴 JWE ขาเข้ามีเพดาน 16KB ต่อ field — ใหญ่กว่านั้นตอบ 400 · ถ้าต้องส่งเอกสาร base64 เข้ามาอย่าใช้ช่องทางนี้
🔴 ขาออกแพงกว่าขาเข้า — ขาเข้าจ่ายเฉพาะ request ที่ FE เลือกส่งเข้ารหัสมา แต่ขาออกจ่ายทุก response ของ endpoint ที่ mark ⇒ เส้น GET ที่ยิงถี่ควรคิดให้ดีก่อน mark
convention เดินอ่าน DTO ตอน startup ครั้งเดียว ไม่ใช่ต่อ request
| request แบบไหน | จ่ายอะไร |
|---|---|
| เข้า endpoint ที่ไม่มี field ทำเครื่องหมาย | อ่าน metadata 1 ครั้งแล้วผ่าน |
| เข้า endpoint ที่มี แต่ flag ปิด | อ่าน metadata + เช็ค DI แล้วผ่าน |
| เข้า endpoint ที่มี และ flag เปิด | อ่าน body ทั้งก้อนเข้า memory + parse JSON + RSA decrypt ต่อ field |
RSA decrypt ~0.1–1 ms ต่อ field เทียบกับ request ทั่วไป 20–50 ms ⇒ สังเกตยาก ยกเว้นเส้นที่ยิงถี่มากหรือ body ใหญ่ ⇒ ใส่เฉพาะ field ที่อ่อนไหวจริง ไม่ใช่ทั้ง body · กุญแจซองที่เปิดแล้วถูก cache ไว้ (10,000 รายการ / 5 นาที) การเปิดซองด้วยแม่กุญแจจึงไม่ได้เกิดทุกครั้ง
ถ้า service คุณใช้ lib เวอร์ชันเก่ากว่า 10.18.0
API เดิมรับ surface เป็น string (AddPayloadFieldDecryption(configuration, "CreateUser")) และต้องลงทะเบียน convention เองใน AddControllers
surface ถูกตัดออกเพราะมันไม่ได้ทำงานอย่างที่ชื่อบอก — ไม่เคยแยกสวิตช์ต่อ endpoint ได้จริง (ตัวถอดรหัสเป็น singleton ระดับแอป เปิดตัวใดตัวหนึ่ง = เปิดหมด) แถมพิมพ์ผิดแล้วเงียบ
อัปเกรด: ตัด argument ที่สองทิ้ง · ลบบรรทัด options.Conventions.Add(new EncryptedFieldMapConvention()); · เปลี่ยน FieldEncryption:Payload:{surface}:Enabled เป็น FieldEncryption:Payload:Enabled · ถ้า service เสิร์ฟ JWKS ด้วย เปลี่ยน flag ของมันเป็น FieldEncryption:Payload:PublishJwks
overload เดิมยังอยู่แบบ [Obsolete] (ไม่สนใจค่า surface) โค้ดเก่าจึงยัง compile ได้และได้ warning แทนที่จะเปลี่ยนพฤติกรรมเงียบ ๆ
C-1 · แปะ attribute
🔴 สิ่งที่ทำให้กลไกนี้ได้ผลจริงคือ คนที่เข้า DB ได้ ต้องไม่ใช่คนเดียวกับคนที่เข้า Key Vault ได้ ถ้าคนเดียวถือทั้งสองอย่าง เข้ารหัสไปก็เท่านั้น
🔴 แปะ [EncryptedField] (ของ B) บน entity = พังทันทีที่ DbContext ถูกใช้ครั้งแรก (ส่วนมากคือตอน startup · error บอกชื่อ property + ตัวที่ควรใช้) — ไม่ปล่อยผ่านเงียบ ๆ เพราะโค้ดจะอ่านเหมือนคอลัมน์นั้นเข้ารหัสแล้ว ทั้งที่ไม่มีอะไรเกิดขึ้นเลย
C-2 · ผูกเข้ากับ model
🔴 ApplyAtRestEncryption ต้องอยู่หลังทุกบรรทัดที่ตั้ง key/index — ตัวตรวจอ่าน model ณ จังหวะที่ถูกเรียก ถ้ามี index ถูกเพิ่มทีหลังมันจะมองไม่เห็น · อย่างอื่นที่ไม่เกี่ยวกับ key/index (เช่น loop ใส่ soft-delete filter) จะอยู่ต่อจากบรรทัดนี้ก็ได้
🔴 ห้ามใส่ if ล้อมบรรทัดนี้ — EF เก็บ model ไว้ตัวเดียวทั้งโปรเซสและไม่ดู argument ของ constructor · ส่ง null เข้าไปได้เลย ตัวมันแทนด้วยตัวส่งผ่านให้เอง รูปของ model จึงเหมือนกันทุก instance
C · เลือกคอลัมน์
2 ข้อแรกตัวโปรแกรมกันให้แล้ว ข้อ 3 ต้องเช็คเอง
- ต้องเป็น string — รูปที่เก็บเป็นตัวหนังสือ
- ห้ามเป็น key หรืออยู่ใน index — ค่าเดิมเข้ารหัสได้หลายแบบ (สุ่มใหม่ทุกครั้ง) ⇒
WHEREหาไม่เจอ ·ORDER BYเรียงตาม ciphertext · unique กันค่าซ้ำไม่ได้อีก · ถ้าจงใจจริง ๆ ให้ผูกHasConversionเองแบบเดิม - คอลัมน์ต้องกว้างพอ — ตัวโปรแกรมเช็คให้ไม่ได้
ความยาวที่เก็บจริง = ceil((ความยาวเดิม + 28) / 3) * 4 + 4 + ความยาวชื่อกุญแจ
+28 คือ nonce 12 ไบต์ + tag 16 ไบต์ · +4 คือ v1: กับ : · ที่เหลือคือชื่อกุญแจ ซึ่งเดินทางไปกับทุกค่า ⇒ ชื่อยาวขึ้น 1 ตัว คอลัมน์ต้องกว้างขึ้น 1 ตัวทุกแถว
ตัวอย่าง: ค่ายาว 43 ตัว + ชื่อกุญแจ kvdev20260830 (13 ตัว) → 113 ตัว · ถ้าใช้ชื่อ k1 → 102 ตัว
varchar(100) รับ plaintext ได้แค่ ~32 ตัว (ชื่อกุญแจ 13 ตัว) · แคบไปจะระเบิดตอน INSERT (Postgres 22001) ไม่ใช่ตอน build ⇒ ต้องทำ migration ขยายก่อน
ข้อมูลเก่ายังเป็น plaintext จนกว่าจะถูกเขียนทับ — จะให้เข้ารหัสย้อนหลังต้องมี backfill ซึ่งเอกสารชุดนี้ไม่ครอบคลุม
string ว่างไม่ถูกเข้ารหัส — เก็บว่างตามเดิม ไม่มี v1: ⇒ อย่าใช้ prefix นับว่าแถวไหน migrate แล้ว
C · กุญแจและการหมุนกุญแจ
🔴 ตั้ง CurrentKeyId ค้างไว้โดยไม่มี Keys = แอปพังตั้งแต่ตอนสร้าง DI แม้สวิตช์จะปิดอยู่ (At least one key must be registered) — เกิดได้จริงเวลากุญแจถูกถอดออกจาก vault แต่ลืมลบชื่อทิ้ง
🔴🔴 ห้ามลบกุญแจออกจาก Key Vault ถ้ายังมีแถวไหนอ้างถึงอยู่ — แถวนั้นจะอ่านไม่ได้ทันที (โยน UnknownKeyId พร้อมบอกชื่อดอกที่หาไม่เจอ ไม่ได้คืนค่าขยะเงียบ ๆ) · นี่คือทางเดียวที่ข้อมูลเก่าจะพัง
🔴 พวงกุญแจถูกอ่านครั้งเดียวตอนสร้าง cipher — ดอกใหม่ไม่เข้าเองระหว่างที่ process ยังอยู่ ต้อง restart
บน prod ตั้งชื่อกุญแจแบบไม่สื่ออะไร (k1 k2) — ชื่อเดินทางไปกับข้อมูล ชื่อที่มีวันที่จะบอกคนที่ได้ dump ไปว่าแถวไหนเขียนช่วงไหน · ตัวชื่อไม่ได้ช่วยให้ถอดรหัสได้ (ต้องมีสิทธิ์ KV อยู่ดี) แต่ไม่ต้องแจกข้อมูลฟรี
service รู้ได้ยังไงว่าต้องดึงพวงกุญแจไหน
ไม่มีที่ไหนในโค้ดไล่ชื่อกุญแจไว้เลย — ทั้งสองทางลงเอยที่ config key AtRestEncryption:Keys:{ชื่อ} แล้ว AddAtRestEncryption() อ่านทั้ง section ⇒ มีกี่ดอกก็ได้เท่านั้น แต่ สองทางนี้ไม่เหมือนกัน
ตัวชี้ขาดคือ KeyVault:VaultUri ของ env นั้นมีค่าหรือไม่ ไม่ใช่ local/cluster:
VaultUri มีค่า | VaultUri ว่าง/null | |
|---|---|---|
| ใครเอากุญแจเข้ามา | แอปกวาด ทั้ง vault เองตอนสตาร์ท แปลง -- เป็น : | CSI driver ตาม รายชื่อที่เขียนไว้ ใน SecretProviderClass → K8s Secret → env var |
| เพิ่มกุญแจดอกใหม่ | สร้าง secret ใน KV แล้ว restart | สร้าง secret + แก้ YAML 2 ที่ แล้ว rollout |
| ดอกที่ไม่ได้อยู่ในรายชื่อ | เห็น | ไม่เห็น — แถวที่อ้างถึงจะถอดไม่ได้ |
🔴 ต้องไปเปิดดูของ service ตัวเอง อย่าเดา — ใน Backend_Iac/config/ 16 จาก 62 คู่ service/env ตั้ง VaultUri เป็น URL จริง (เช่น log-service/dev, task-service/sit, fxrate-service ทั้ง 3 env) ที่เหลือเป็นค่าว่าง ⇒ กติกาไม่เหมือนกันแม้แต่ระหว่าง env ของ service เดียวกัน
grep -n VaultUri Backend_Iac/config/{service}/{env}/appsettings.json
⇒ ว่าง = “หมุนกุญแจโดยไม่แตะ IaC” ทำไม่ได้ ต้องแก้ SPC ทุกครั้ง · มีค่า = กวาดเองได้เหมือน local
เปิดเป็นราย env
⚠️ guard ของ parity ไม่ครอบทุกกรณี — (1) ถ้า IaC ไม่มีไฟล์ config ของ service/env นั้นเลย overlay จะข้ามเงียบ ๆ ไม่ fail (แล้วค่าที่ได้คือค่าที่ติดมากับ image) (2) parity ตรวจเฉพาะ env ที่ build รอบนั้น ⇒ ลืม uat จะไม่มีใครรู้จนกว่าจะ build uat
🔴 ห้ามใส่ true ใน appsettings.json ตัวหลัก — ไฟล์นั้น overlay ไม่ทับ ⇒ ติดไปทุก env รวม prod · ที่นั่นใส่ได้แค่ false
🔴 ตอนนี้ยังตั้งค่าฝั่ง Prod ไม่ได้
Backend_Iac/config/ มีแค่ dev sit uat — ทั้ง 22 service ไม่มีโฟลเดอร์ prod เลย และไม่มี prod/prd ที่ไหนในรีโป
⇒ วันนี้ทำได้แค่ยืนยันว่า dev/sit/uat ปิด · ค่าที่จะเปิดบน prod ต้องรอ overlay ของ prod ถูกสร้างก่อน ถึงจะมีที่ให้ใส่ นี่เป็นงานที่ยังไม่มีใครทำ
B ต้องเปิด/ปิดพร้อมกันสองฝั่ง — A กับ C ไม่ต้อง
| ปิดฝั่ง BE แล้ว FE ยังทำงานเดิม | เกิดอะไรขึ้น |
|---|---|
| A | header ที่ FE ส่งมาถูกมองข้าม — ไม่มีผล |
| C | ไม่เกี่ยวกับ FE เลย |
| B | field ที่ FE ปิดผนึกมาไหลเข้า handler เป็นข้อความ JWE ยาว ๆ ไม่มี error ไม่มี log — แล้วไปลง DB แบบนั้น |
FE ตัดสินใจเข้ารหัสที่ call site (encryptFields(...) ในหน้าที่เรียก) ไม่ได้ผูกกับ env ⇒ ปิด B ที่ env ไหน ต้องแน่ใจว่าไม่มีหน้าไหนใน env นั้นยังเรียก encryptFields อยู่
ข้อสังเกตก่อนเลือก “เปิดเฉพาะ prod”
prod จะกลายเป็นที่แรกที่กลไกได้รันจริง · A มีขั้นกลางให้ (Enabled: true + Mode: LogOnly = ตรวจแล้ว log ไว้ แต่ยังปล่อยผ่าน) แต่ B กับ C ไม่มีขั้นกลาง — ปิดคือไม่ทำ เปิดคือทำเต็ม
ทางที่เสี่ยงน้อยกว่าคือเปิด dev ก่อน (ซึ่งตอนนี้ทำแล้วทั้ง A B C บน UserService/Codex) แล้วค่อยขยับขึ้น — แต่เป็นการตัดสินใจของ Owner ไม่ใช่ของเอกสาร
API ที่ชื่อคล้ายกัน — ตัวไหนคือตัวจริง
| งาน | ✅ ใช้ | ❌ อย่าใช้ |
|---|---|---|
| client signature | AddClientSignature() + AddClientSignatureStores() | AddSignatureValidation() · AddSignatureAuth() |
| at-rest encryption (C) | AddAtRestEncryption() + ApplyAtRestEncryption() | AddAtRestFieldEncryption() (เก่า) · AddFieldEncryption() · AddFieldEncryptionCipher() |
AddJwksKeyMaterial() / AddJwksDiscovery() มีอยู่ใน lib แต่เป็นของ Sentinel เท่านั้น service ทั่วไปไม่ต้องเรียก
กับดักที่ build ผ่านแต่พังตอนยิงจริง
services.AddScoped<IClientKeyStore, RedisClientKeyStore>(); // ❌
services.AddClientSignatureStores(configuration); // ✅
RedisClientKeyStore ต้องการ prefix ตอนสร้าง — register แบบ type-to-type ไม่ได้ส่งให้
ถ้า service คุณยังใช้ชื่อ config เก่าของ at-rest — FieldEncryption:AtRest:*
ชื่อเก่ายังอ่านได้อยู่ (FieldEncryption:AtRest:Enabled, FieldEncryption:CurrentKeyId, FieldEncryption:Keys) และ AddAtRestFieldEncryption() ยังเรียกได้แบบ [Obsolete] — ย้ายเมื่อไหร่ก็ได้ ไม่ต้องรีบ
ตอนย้าย: ย้ายทั้ง 3 อย่างพร้อมกัน (secret ใน KV, key ใน appsettings, ชื่อ method) · ถ้าตั้งทั้งชื่อใหม่และชื่อเก่าไว้พร้อมกัน ชื่อใหม่ชนะ
ข้อความ error บอกชื่อ section ตามชุดที่คุณใช้อยู่จริง ไม่ได้ชี้ไปที่ไฟล์ที่คุณไม่มี (ก่อน 10.23.0 ข้อความตอน runtime ยัง hardcode ชื่อเก่าอยู่)
KeyVault — กุญแจชุดเดียว ทุก service อ่านตัวเดียวกัน
secret ของ FieldEncryption เป็น shared — ตั้งชื่อโดยไม่มี env ไม่มี service แบบเดียวกับ Encryption--Key และ Subscription--Unsubscribed--HmacTokenSecret ที่มีอยู่แล้วใน SPC จริง
AtRestEncryption--Keys--kvdev20260830 ← AES ของ C (at-rest)
FieldEncryption--Keys--localdev1 ← กุญแจเก่า ไว้อ่านแถวที่เข้ารหัสด้วยตัวเก่า
FieldEncryption--Payload--Keys--kvdev20260827 ← RSA public
FieldEncryption--Payload--PrivateKeys--kvdev20260827 ← RSA private (ใช้ถอดรหัส)
service ของคุณไม่ต้องสร้าง secret ใหม่ — อ่านชุดนี้ได้เลย
ทำไมต้องไม่มี env ในชื่อ
ServicePrefixKeyVaultSecretManager ของ lib แปลงชื่อ secret เป็น config key แบบนี้
| ชื่อ secret | config key ที่ได้ |
|---|---|
{env}--{service}--FieldEncryption--Keys--X | FieldEncryption:Keys:X ✅ (ตัดด้วย KeyVault:ServicePrefix) |
FieldEncryption--Keys--X | FieldEncryption:Keys:X ✅ |
{env}--FieldEncryption--Keys--X | {env}:FieldEncryption:Keys:X ❌ |
🔴 แถวสุดท้ายคือกับดัก — lib ตัดเฉพาะ prefix ที่ตรงกับ KeyVault:ServicePrefix เต็ม ๆ เท่านั้น ใส่ env อย่างเดียวจะได้ key ที่ไม่มีใครอ่าน ค่าหายเงียบ ไม่ error ไม่เตือน แล้วไปโผล่ทีหลังเป็นถอดรหัสไม่ผ่าน
การแยกกุญแจต่อ env ทำผ่าน kid แทน (kvdev20260830 มี env อยู่ในชื่ออยู่แล้ว) — คนละ env ก็คนละ kid คนละ secret
CurrentKeyId วางที่ appsettings ไม่ใช่ KV
มันคือชื่อของกุญแจ ไม่ใช่ตัวกุญแจ — ไม่ใช่ความลับ และต้องต่างกันตาม env ⇒ วางใน appsettings.{ENV}.json + IaC config (อย่าลืม parity ทั้งสองที่)
จะวางใน KV ก็ได้ แต่ต้องเป็นชื่อที่มี prefix ({env}--{service}--AtRestEncryption--CurrentKeyId) เพราะ vault non-prod มีตัวเดียวใช้ร่วมทุก env — ตั้งเป็น shared เมื่อไหร่ ทุก env จะเขียนด้วยกุญแจดอกเดียวกันทันที
มีกุญแจดอกเดียวไม่ต้องตั้งเลย — ไม่มีอะไรให้ชี้ ชื่อนี้เริ่มมีความหมายวันที่มีดอกที่สอง
บน cluster ยังต้องแก้ SecretProviderClass
src/yamls/{env}/superapp/secret-providers/{ชื่อสั้น}-secret-provider.yaml ใน Backend_Iac — เพิ่มใน objects array
🔴 ชื่อไฟล์ไม่ตรงกับชื่อโฟลเดอร์ใน config/ — ตัด -service ออก และบางตัวเขียนติดกัน (config/user-service/ → user-secret-provider.yaml · config/file-management-service/ → filemanagement-secret-provider.yaml) · และ src/yamls/ มีแค่ dev กับ uat ไม่มี sit ⇒ ของ sit ยังไม่มีที่ให้เติม
spec:
parameters:
objects: |
array:
- |
objectName: AtRestEncryption--Keys--kvdev20260830
objectType: secret
objectAlias: AtRestEncryption__Keys__kvdev20260830
secretObjects:
- secretName: {service}-secrets
data:
- objectName: AtRestEncryption__Keys__kvdev20260830
key: AtRestEncryption__Keys__kvdev20260830
🔴 ต้องเขียน 2 ที่ต่อกุญแจ 1 ดอก — objects (ดึงจาก KV ลง volume) กับ secretObjects (sync เป็น K8s Secret ที่ deployment ใช้ envFrom) · ใส่แค่ที่แรก = ไฟล์ mount มา แต่ไม่มี env var แอปไม่เห็นอะไรเลย
objectAlias คือชื่อ env var ที่แอปจะเห็น · __ คือตัวคั่น section ของ .NET และ env var ชนะ appsettings ทุกชั้น
ไม่มี wildcard — CSI ระบุทีละ secret ⇒ ถ้า env นั้น KeyVault:VaultUri ว่าง กุญแจดอกใหม่ = แก้ YAML ทุกครั้ง · ถ้า VaultUri มีค่า แอปกวาด vault เองได้เหมือน local ไม่ต้องแตะ SPC
แล้วแอปเห็นค่าใหม่ได้ยังไง — CSI ไม่ได้แก้ appsettings
สองตัวนี้คนละอันกัน แต่ชอบสลับกัน:
| ทำตอนไหน | ทำอะไร | |
|---|---|---|
| IaC config overlay | ตอน build image | cp ไฟล์ IaC ทับ appsettings.{ENV}.json — ตัวนี้ต่างหากที่ “เอาค่าไปใส่ appsettings” |
| CSI SecretProviderClass | ตอน pod เกิด | ดึง secret จาก KV → sync เป็น K8s Secret → envFrom ใส่เป็น env var ในคอนเทนเนอร์ |
CSI ไม่เคยแตะไฟล์ appsettings เลย และแอปก็ไม่ได้ “ไปดึง” จาก KV — ค่าถูกยัดใส่มือตั้งแต่คอนเทนเนอร์เกิด แล้ว .NET อ่าน env var เป็น config source ที่ precedence สูงสุด ทับ appsettings ทุกชั้น (__ = :)
secret ใน KV: AtRestEncryption--Keys--k1
↓ CSI (objects + secretObjects)
env var: AtRestEncryption__Keys__k1
↓ .NET config
config key: AtRestEncryption:Keys:k1 ← GetSection("AtRestEncryption:Keys") เห็นตรงนี้
⇒ ต้อง restart pod เสมอ · env var ของ process ที่รันอยู่แล้วเปลี่ยนไม่ได้ envFrom ถูกอ่านครั้งเดียวตอนสร้างคอนเทนเนอร์
🔴 สถานะตอนนี้
grep FieldEncryption ใน src/yamls/ ของ origin/development = 0 และไม่มี commit ไหนเคยแตะ ⇒ ยังไม่มี service ไหน mount กุญแจนี้บน cluster เลย ที่ทำไว้ใช้ได้เฉพาะเครื่อง local
sit/uat ยังไม่มีกุญแจ ⇒ ก่อนเปิด flag บน env ไหน ต้องสร้างกุญแจของ env นั้น (kid ใหม่) แล้วแก้ SPC ก่อน
หมายเหตุ: เรื่องนี้เป็นของ FieldEncryption อย่างเดียว · ClientSignature ไม่มีกุญแจฝั่ง server เลย — กุญแจของผู้ใช้อยู่ใน Redis ไม่ใช่ KV