Body Signature — แผน Implement รอบ POC
แผนลงมือจริงรอบแรก (Backend 2 repo) — blocker ที่เจอในโค้ด, wire contract ที่ freeze แล้ว, ไฟล์ที่จะแตะ, ลำดับงาน และสิ่งที่ยังต้องตัดสิน
อัปเดต: 2026-07-31
Body Signature — แผน Implement รอบ POC
คู่กับ ภาพระบบหลัง Implement — เล่มนั้นคือ “จะเป็นแบบไหน” เล่มนี้คือ “จะทำยังไง รอบนี้ทำแค่ไหน”
สถานะ ณ 31/07/2026: ยังไม่มีโค้ดถูกแก้แม้แต่บรรทัดเดียว — เอกสารนี้คือแผนที่ผ่านการตรวจโค้ดจริง 5 repo, ผ่าน security review, ผ่านการท้าทายจาก skeptic และ ผ่านการพิสูจน์ด้วยเบราว์เซอร์จริง + .NET จริงแล้ว (ข้อ 8)
1. รอบนี้ได้อะไร — และยังไม่ได้อะไร
ได้ (Backend 2 repo)
SupApp_util_libมีความสามารถตรวจ ลายเซ็นแบบกุญแจคู่ต่อผู้ใช้ เพิ่มเข้ามา โดยของเดิม (HMAC ที่ ~13 service ใช้อยู่) ไม่ถูกแตะแม้แต่ byte เดียว- Codex มี endpoint รับลงทะเบียน public key แบบ TOFU (คนแรกชนะ) และมี endpoint นำร่องที่ บังคับลายเซ็นจริง — ไม่มีลายเซ็น/ลายเซ็นผิด/เวลาเก่า = 401
- ครอบ GET ด้วย ตามมติ — GET ที่ไม่มีลายเซ็นถูกปฏิเสธจริง ไม่ถูกกฎ “ข้าม GET” ของเดิมเล็ดลอด
- มีชุดทดสอบที่พิสูจน์ทั้งเคสผ่านและเคสที่ต้องพัง รวม edge ของการประกอบข้อความ (query ภาษาไทย, ช่องว่าง,
+, BOM, ลายเซ็นผิดรูปแบบ)
ยังไม่ได้ (เลื่อนไปรอบหน้าโดยตั้งใจ)
- ฝั่ง Angular ทั้งหมด — interceptor ใน
@exim/auth-sdk+ การต่อเข้า shell + การทดสอบผ่าน browser จริง หลักฐานว่า 2 ฝั่งเซ็น/ตรวจตรงกันจริง→ ปิดช่องนี้ไปแล้ว 31/07 ด้วย spike ก่อนเริ่มเขียนโค้ด (ดูข้อ 8)- Sentinel / APIM / CSP / field encryption / Sonar-coverage — อยู่นอกรอบนี้ทั้งหมด
2. 4 เรื่องที่เจอในโค้ดจริง และเกือบทำให้แผนเดิมพัง
ทั้ง 4 ข้อไม่ได้มาจากการเดา — ตรวจจากไฟล์จริงทั้งหมด
| # | เจออะไร | ถ้าไม่รู้ก่อนจะเกิดอะไร | ปิดยังไง |
|---|---|---|---|
| 1 | SecurityFilterExtensions.cs:85 สร้าง HmacSignatureService ทันทีที่ register และ HmacSignatureService.cs:24-25 โยน exception เมื่อไม่มี secret · Codex ไม่มี SecretKey ทั้งใน appsettings และ IaC | Codex boot ไม่ขึ้นเลย ทันทีที่เปิด signature ตามแผนเดิม | แยกเป็นคลาสใหม่ที่ไม่พึ่ง HMAC เลย (ดูข้อ 4) |
| 2 | ตัวกรองลายเซ็นไม่เคยถูกต่อเข้า pipeline — ApiExtensions.cs:87-88 มีแค่ ApiKey + Idempotency | ติด attribute ไปก็ไม่มีผลอะไร เข้าใจผิดว่าปลอดภัยแล้วทั้งที่ไม่ตรวจอะไรเลย | ใช้ [ServiceFilter] ระบุตัวกรองตรงๆ ที่ controller นำร่อง |
| 3 | ตัวกรองเดิมเช็ค “ข้าม GET” ก่อนอ่าน header (SignatureValidationFilter.cs:50) | มติ “ครอบ GET” ถูกเลี่ยงได้ด้วยการไม่ส่ง header เลย → 200 · และชุดทดสอบยังผ่านแบบหลอกๆ | คลาสใหม่ไม่มีกลไก “ข้าม GET” อยู่เลย ปิดโดยโครงสร้าง |
| 4 | X-Key-Id ไม่อยู่ใน Cors:AllowedHeaders (appsettings.{Development,SIT,Uat}.json:31-37 และ IaC :51) และ CORS ใช้ allowlist ไม่ใช่ allow-any | preflight ตก → browser ไม่เคยส่ง signed request สำเร็จสักตัว และการเติม header นี้จะลาก IaC ทั้ง 3 env เข้ามาด้วย | ตัด X-Key-Id ออกจากรอบนี้ — เหลือ 2 header ที่อยู่ใน allowlist อยู่แล้ว |
3. ข้อความที่เอาไปเซ็น (freeze แล้ว ห้ามตีความเอง)
v1\n{ts}\n{METHOD}\n{path}\n{query}\n{base64(sha256(body))}
ทุกช่องคั่นด้วยขึ้นบรรทัดใหม่ ไม่ใช่จุด — เพราะจุดเป็นอักขระที่อยู่ใน path และ body ได้ ทำให้ /a + .x กับ /a. + x กลายเป็นข้อความเดียวกัน (security review จับได้)
| ส่วน | กติกา |
|---|---|
ts | Unix seconds · ต่างจากเวลาเซิร์ฟเวอร์เกิน 300 วิ = ปฏิเสธ |
METHOD | ตัวพิมพ์ใหญ่ |
path | เริ่มที่ /api/codex-service/... · ตัด / ท้าย ยกเว้น root · มี % หรือ .. = ปฏิเสธ |
query | แตกออก → ถอดรหัส → เรียงตาม (ชื่อ, ค่า) → เข้ารหัสใหม่ด้วยชุดอักขระ A-Za-z0-9-._~ เท่านั้น (ช่องว่าง = %20, บวกจริง = %2B) → ต่อด้วย & · ไม่มี query = ค่าว่าง · ห้ามยุบคีย์ซ้ำ |
body | hash จาก bytes ดิบ อ่านจาก stream เอง — ห้ามใช้ body ที่ middleware แคชไว้ (มันตัด BOM ทิ้ง = ลายเซ็นเพี้ยนเป็นครั้งคราวแบบหาสาเหตุยากมาก) · GET = hash ของค่าว่าง |
| ลายเซ็น | ECDSA P-256 + SHA-256 · รูปแบบ raw r‖s 64 bytes เป๊ะ (ไม่ใช่ DER) · ส่งเป็น base64 ปกติ |
| การหา key | จาก oid ใน JWT ที่ผ่านการตรวจแล้ว เท่านั้น · หาไม่เจอ = 401 และ ห้ามถอยไปใช้ HMAC |
| การลงทะเบียน | เขียน Redis แบบ atomic (เขียนได้เฉพาะเมื่อยังว่าง) · ส่ง JWK เดิมซ้ำ = สำเร็จเงียบๆ (กันกดรีเฟรชแล้วเด้ง) · ส่ง JWK ใหม่ทับ = 409 + log เตือน |
4. จะเพิ่มอะไรที่ไหน
Backend_Package — เพิ่มของใหม่ล้วน 4 ไฟล์ ไม่แก้ตัวเก่าเลย
| ไฟล์ | ทำอะไร |
|---|---|
Abstractions/Security/ISignatureKeyResolver.cs | ช่องเสียบ “หา public key ของ user นี้จากไหน” (Codex เป็นคนตอบ) |
Infrastructure/Security/EcdsaClientSignatureVerifier.cs | ประกอบข้อความตามข้อ 3 + ตรวจลายเซ็น |
Filters/ClientSignatureValidationFilter.cs | คลาสใหม่ทั้งตัว — ไม่มีกลไกข้าม GET, ไม่พึ่ง HMAC |
Extension/ClientSignatureExtensions.cs | ตัวลงทะเบียนเข้า DI |
การแยกเป็นคลาสใหม่ปิด blocker ข้อ 1 และ 3 พร้อมกัน และทำให้พิสูจน์ “ของเดิมไม่ถูกแตะ” ได้ด้วยคำสั่งเดียว: git diff ของไฟล์เดิม = ว่างเปล่า
Backend_CodexService — endpoint ใหม่บน controller ใหม่
| ไฟล์ | ทำอะไร |
|---|---|
Codex04.Domain/Ports/Security/IClientKeyStore.cs | สัญญาของที่เก็บ key (ตามกฎ interface อยู่ Domain) |
Codex02.Infrastructure/Security/RedisClientKeyStore.cs | เก็บ/อ่าน public key ใน Redis + เป็นตัวตอบให้ Package · key ตั้ง TTL เสมอ |
Codex03.Application/Features/ClientKeys/... | Command + Validator + Handler (3 ไฟล์ ตามโครงเดิมของ repo) |
Codex01.API/Controllers/v1/ClientKeysController.cs | POST client-keys (ลงทะเบียน) · GET client-keys/ping และ POST client-keys/echo (บังคับลายเซ็น) |
DependencyInjection.cs + Codex04.Domain.csproj + appsettings.json | ต่อสาย + อัปเวอร์ชัน lib + ค่าคอนฟิกที่ไฟล์ base |
:::details ทำไม endpoint นำร่องเป็นของใหม่ ไม่ไปแขวนกับ endpoint เดิม endpoint เดิมของ Codex (Mdm*, AppManagement, CmsConfig) ถูกเรียกจาก Admin Portal ซึ่งยังไม่เซ็นอะไรเลย ถ้าไปบังคับลายเซ็นตรงนั้น = ระบบพังทันทีบน dev · endpoint นำร่องที่ไม่มีใครเรียกอยู่ ทำให้ “เปิดใช้จริง” ได้โดยไม่มีผลกระทบ และยังพิสูจน์ทุกเคสได้ครบ
ผลข้างเคียงที่ต้องรู้: appsettings.{ENV}.json จะไม่ถูกแตะเลย (คอนฟิกลงที่ไฟล์ base) ซึ่งแปลว่า IaC ไม่ต้องแก้ และด่าน config-parity ใน CI ไม่ถูกกระตุ้น
:::
5. ลำดับงาน
ประตูด่านแรก — spike WebCrypto✅ ผ่านแล้ว 31/07 (ข้อ 8) — และได้ลายเซ็นจริงจากเบราว์เซอร์มาใช้เป็นชุดทดสอบตายตัวของฝั่ง .NET ด้วย- เขียนชุดทดสอบที่ยึดพฤติกรรมปัจจุบันของ HMAC ไว้ก่อน แล้วรันให้เขียว — เป็นตาข่ายจับว่าของเดิมไม่พัง
Backend_Packageเขียนของใหม่ 4 ไฟล์ → รันเทสต์ทั้ง repodotnet pack→ ตรวจว่าไฟล์ในแพ็กเกจมีของใหม่จริง → publish ขึ้น feed ด้วย feed token (เคยเจอเคส pack ติด build เก่ามาแล้ว และ feed ดันทับเวอร์ชันเดิมไม่ได้ ผิดแล้วต้องขึ้นเลขใหม่)Backend_CodexServiceเขียน + อัปเวอร์ชัน lib → build + รันเทสต์เดิมทั้งหมดให้ยังเขียว- ยิง HTTP จริงใส่ Codex ที่รันอยู่ 7 เคส (ลงทะเบียน / ping ลายเซ็นถูก / echo ลายเซ็นถูก / ไม่มี header / body เพี้ยน / ไม่เคยลงทะเบียน / ลงทะเบียนซ้ำด้วย key ใหม่)
- review อิสระ + ตรวจว่าไฟล์ต้องห้ามไม่ถูกแตะ
6. เกณฑ์ว่าสำเร็จ
:::details 14 เกณฑ์ + ชุดทดสอบเต็ม (กางดูเมื่อจะตรวจรับ) เคสที่ต้องผ่าน: ลายเซ็นถูก → 200 ทั้ง GET และ POST · ข้อความที่เซ็นตรงสเปกแบบ byte-for-byte
เคสที่ต้องถูกปฏิเสธ: ไม่มี header → 401 “Missing Signature” · เวลาเก่ากว่า 300 วิ → 401 “Expired Request” (แม้ลายเซ็นถูก) · GET ไม่มีลายเซ็น → 401 · body หรือ query เพี้ยน 1 byte → 401 “Invalid Signature” · ไม่เคยลงทะเบียน key → 401 และพิสูจน์ด้วย spy ว่าไม่มีการเรียก HMAC · ไม่มีตัวหา key ใน DI → 401 ไม่ใช่ crash
ขอบที่ต้องทดสอบ: query ภาษาไทย · ช่องว่าง (ต้องเป็น %20) · เครื่องหมายบวกจริง (ต้องเป็น %2B) · คีย์ซ้ำ ?a=1&a=2 (ห้ามยุบ) · ไม่มี query · body มี BOM (ดักว่าอ่าน bytes ดิบจริง ไม่ได้ใช้ตัวที่แคชไว้) · body ภาษาไทย/emoji · body มีจุดและมีขึ้นบรรทัดใหม่ · GET body ว่าง · ลายเซ็น 63/65 bytes → ปฏิเสธ · ลายเซ็นแบบ DER → ปฏิเสธ · เวลาที่ขอบพอดี 300 วิ → ผ่าน, 301 → ไม่ผ่าน
การลงทะเบียน: ครั้งแรก → สำเร็จ · JWK เดิมซ้ำ → สำเร็จเงียบ · JWK ใหม่ทับ → 409 + log เตือน + ของเดิมไม่เปลี่ยน · JWK ที่ไม่ใช่ EC/P-256, มี private component, x/y ไม่ใช่ 32 bytes → ปฏิเสธ ไม่เขียนลง Redis
ของเดิมต้องไม่พัง: เทสต์เดิมของ Package ผ่านโดยไม่ถูกแก้ · git diff ของ 4 ไฟล์ต้องห้าม = ว่าง · เทสต์เดิมของ Codex ผ่านหลังอัปเวอร์ชัน lib · endpoint เดิมของ Codex ไม่ต้องการลายเซ็น
สิ่งที่ต้องยืนยันว่าไม่เกิด: ไม่มีการถอยไปใช้ HMAC เมื่อหา key ไม่เจอ · ไม่มี log ที่มี body ดิบหรือค่าลายเซ็น · Redis key ประกอบจาก oid เท่านั้น ห้ามจากค่าที่ client ส่งมา · ไม่มีการเปิด middleware ตัวเก่า · ไม่มี attribute ไปโผล่บน controller เดิม :::
7. สิ่งที่รอบนี้ยังไม่กัน (พูดให้ชัด อย่าขายเกิน)
| เรื่อง | สถานะ |
|---|---|
| XSS | ไม่กัน — สคริปต์ในหน้าเดียวกันสั่งเซ็นได้ · คุณค่าจริงของ key แบบ export ไม่ได้ คือ “ขโมยออกไปใช้ที่เครื่องอื่นไม่ได้” ไม่ใช่ “สคริปต์ในหน้าใช้ไม่ได้” |
| ยิงซ้ำภายใน 300 วิ | ยังทำได้ (ยังไม่มีตัวกันซ้ำ) · รับได้เพราะ endpoint นำร่องไม่มีผลข้างเคียง แต่ ต้องเพิ่มตัวกันซ้ำก่อนเอาไปใช้กับ endpoint จริง |
| ขโมย cookie แล้วชิงลงทะเบียนก่อนเจ้าของ | ช่องแคบๆ ที่เหลือของ TOFU — ปิดสนิทได้ตอนย้ายจุดผูก key ไป Sentinel |
| ผู้ใช้ล้าง browser / ใช้หลายเครื่อง | จะติด 409 · รอบนี้แก้ด้วยการตั้งอายุ key ไว้เสมอ (หมดอายุแล้วลงทะเบียนใหม่ได้) — ยังไม่มีการจัดการหลายอุปกรณ์ |
| HMAC ยังเปิดอยู่ในระบบ | ใครถือ shared secret ยังใช้ทางเดิมได้ · ไม่กระทบ endpoint นำร่อง (มันไม่รับ HMAC เลย) แต่ต้องรู้ไว้ตอนขยายผล |
8. พิสูจน์แล้วก่อนเริ่มเขียนโค้ด (31/07/2026)
สมมติฐานที่ทั้งแผนตั้งอยู่บนมัน ถูกทดสอบด้วยของจริงทั้งสองฝั่งแล้ว — ก่อนเขียนโค้ดสักบรรทัด
ฝั่งเบราว์เซอร์ (Edge 150 บนเครื่อง Owner) — ผ่านทั้ง 3 ด่าน
| ด่าน | ผล |
|---|---|
สร้างกุญแจ extractable:false แล้วยังดึง public key ออกมาได้ | ✅ ได้ JWK ครบ (EC / P-256 / x,y ยาว 32 bytes) |
ลายเซ็นเป็น raw r‖s 64 bytes (ไม่ใช่ DER) | ✅ 64 bytes |
| เก็บกุญแจข้ามการรีเฟรชหน้าได้ | ✅ IndexedDB เก็บ CryptoKey ได้ |
ฝั่ง .NET — เอาลายเซ็นที่เบราว์เซอร์เพิ่งสร้าง มาให้ .NET ตรวจด้วยกติกาเดียวกับที่จะ implement จริง ผ่านทั้ง 9 ข้อ
PASS body hash .NET == browser PASS same bytes rejected when read as DER
PASS JWK x is 32 bytes PASS tampered body rejected
PASS JWK y is 32 bytes PASS different method rejected
PASS signature is 64 bytes (P1363) PASS different path rejected
PASS VERIFY browser signature with IeeeP1363
ALL CHECKS PASSED - browser and .NET agree on the wire contract
ทำไมข้อนี้สำคัญกว่าที่เห็น: bug ที่ฆ่างานประเภทนี้บ่อยที่สุดคือ “สองฝั่งพูดคนละภาษาโดยไม่รู้ตัว” — ฝั่ง .NET เขียนเทสต์เอง ตรวจลายเซ็นที่ตัวเองสร้าง จะเขียว 100% แม้รูปแบบจะเข้ากับเบราว์เซอร์ไม่ได้เลย แล้วไปแตกตอนต่อ FE ซึ่งตอนนั้นแพงกว่ามาก · ตอนนี้เรารู้แน่แล้วว่าเข้ากันได้ และรู้ด้วยว่าถ้าใครเผลอเปลี่ยนรูปแบบเป็น DER มันจะไม่ผ่าน (ข้อ 6 ในผลทดสอบ)
ทั้ง 3 การตัดสินที่ค้างอยู่ ปิดครบแล้ว
| เรื่อง | มติ |
|---|---|
| สมมติฐาน WebCrypto | ✅ พิสูจน์แล้วตามตารางข้างบน |
| เวลา | ✅ Owner อนุมัติขยายงบเวลา |
| การเผยแพร่แพ็กเกจ | ✅ publish ผ่าน feed จริงทั้งสองฝั่ง (NuGet และ npm) ด้วย feed token — ไม่ใช้ local feed · ตามด้วยผลที่ต้องระวัง: เลขเวอร์ชันที่ push แล้วดันทับไม่ได้ ดังนั้นก่อน push ต้องตรวจว่าไฟล์ในแพ็กเกจมีของใหม่จริง (dotnet pack เคยแพ็ค build เก่ามาแล้ว) |
9. รอบถัดไป (FE)
@exim/auth-sdk เพิ่มตัวจัดการกุญแจ + interceptor → shell ต่อสาย → publish 1.6.0 ขึ้น feed → ทดสอบผ่าน browser จริง (login จริง ไม่ seed ข้อมูล)
งานที่ต้องระวังไว้ล่วงหน้า — เจอไว้แล้วตอนสำรวจ:
- shell ประกาศคอนฟิกซ้ำ 2 ที่ (
app.config.tsบรรทัด 102-108 และ 119) ตัวหลังชนะ — ถ้าใส่ค่าผิดที่ interceptor จะเงียบสนิทโดยที่ build ยังเขียว จับได้เฉพาะตอนเปิดเบราว์เซอร์ดู Network tab - jest ทดสอบ WebCrypto/IndexedDB ไม่ได้ (jsdom ไม่มีให้) → ต้องแยก “ตัวประกอบข้อความที่จะเซ็น” ออกมาเป็นฟังก์ชันบริสุทธิ์ตั้งแต่แรก จะได้ทดสอบเทียบกับชุดตายตัวเดียวกับฝั่ง .NET ได้
- spec ของ interceptor เดิมในไลบรารี (
csrf.interceptor.spec.ts) มี mock ที่รูปทรงไม่ตรงกับที่โค้ดอ่านจริง — ห้าม copy เป็นแม่แบบ
10. สถานะล่าสุด
| โค้ดที่แก้ไปแล้ว | 0 บรรทัด ทั้ง 5 repo |
| ผ่านแล้ว | สำรวจโค้ดจริง 5 repo · security review · skeptic challenge · test charter 14 เกณฑ์ · พิสูจน์ cross-stack ด้วยของจริง 9/9 |
| ค้างที่ | รอ Owner สั่งเริ่มเขียนโค้ด |
| ถัดไปทันทีเมื่อได้ไฟเขียว | Backend_Package 4 ไฟล์ใหม่ (ไม่แตะของเดิม) → เทสต์ → pack+publish → Backend_CodexService endpoint ใหม่ → เทสต์ → ยิง HTTP จริง 7 เคส → review |