Private Docs

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 มีเส้นแบบนี้ค้างอยู่ไม่มากก็น้อย

  1. ทำ inventory ก่อน — ไล่ทุก action แยกเป็น 3 กองตามตารางในหน้าติดตั้ง ยังไม่ต้องแก้อะไร
  2. หา caller จริงของกองที่ 3 — grep ชื่อ endpoint ในรีโปอื่น (service อื่น / FE) · caller เป็น service = เข้าเกณฑ์ [SkipClientSignature] · เรียกจากหน้าที่ login แล้ว = เข้าเกณฑ์ [Authorize]
  3. ที่ยังหาไม่เจอ ส่ง list ให้ Owner ตัดสิน พร้อมบอกผลกระทบ — อย่าใส่ [Authorize] เอาเอง เดาผิด = caller ที่ทำงานอยู่พังเงียบ
  4. ระหว่างรอ ห้ามเปิด 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 บอกว่า ตอนนี้เปิดใช้หรือยัง สองอย่างนี้เปลี่ยนคนละจังหวะ

  1. deploy โค้ดก่อน เปิดทีหลังได้ — ใส่ attribute ขึ้น production ได้โดยยังไม่เปิด ไม่มีอะไรเปลี่ยนสำหรับ caller
  2. เป็น kill switch — มีปัญหาแล้วปิดกลับด้วย config ไม่ต้อง revert โค้ด (แต่ต้อง restart)
  3. เปิดโดยกุญแจยังไม่ถึง 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 ต้องเช็คเอง

  1. ต้องเป็น string — รูปที่เก็บเป็นตัวหนังสือ
  2. ห้ามเป็น key หรืออยู่ใน index — ค่าเดิมเข้ารหัสได้หลายแบบ (สุ่มใหม่ทุกครั้ง) ⇒ WHERE หาไม่เจอ · ORDER BY เรียงตาม ciphertext · unique กันค่าซ้ำไม่ได้อีก · ถ้าจงใจจริง ๆ ให้ผูก HasConversion เองแบบเดิม
  3. คอลัมน์ต้องกว้างพอ — ตัวโปรแกรมเช็คให้ไม่ได้
ความยาวที่เก็บจริง = 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 ยังทำงานเดิมเกิดอะไรขึ้น
Aheader ที่ FE ส่งมาถูกมองข้าม — ไม่มีผล
Cไม่เกี่ยวกับ FE เลย
Bfield ที่ 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 signatureAddClientSignature() + 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 แบบนี้

ชื่อ secretconfig key ที่ได้
{env}--{service}--FieldEncryption--Keys--XFieldEncryption:Keys:X ✅ (ตัดด้วย KeyVault:ServicePrefix)
FieldEncryption--Keys--XFieldEncryption: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 imagecp ไฟล์ 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