Private Docs

Security Uplift · A — Body signature

ติดตั้งการตรวจลายเซ็นของผู้ใช้ในทุก request: DI, pipeline, appsettings, การเลือกเส้นด้วย [RequireClientSignature] และวิธีตรวจว่าติดตั้งสำเร็จ

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

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

A. Body signature — ติดตั้ง 5 ขั้น

ทุก service ต้องทำ · ไม่ต้องแตะ KeyVault

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

sequenceDiagram
    autonumber
    participant B as Browser (auth-sdk)
    participant G as APIM facade
    participant M as UseClientSignatureVerification
    participant R as Redis
    participant H as Handler

    B->>B: ประกอบ stringToSign = v1 + timestamp + METHOD + path + query + base64(sha256(body))
    B->>G: request พร้อม X-Signature และ X-Timestamp
    G->>G: cookie ผ่าน resolve-session เป็น Bearer แล้วตัด route prefix ทิ้ง
    G->>M: forward — path ที่ service เห็นคือ path ที่ต้องถูกเซ็น
    Note over M: UseAuthentication · UseRedisUserInfo · UseAuthorization รันไปก่อนหน้านี้แล้ว
    alt ClientSignature Enabled = false
        M->>H: ผ่านทันที ไม่ตรวจอะไรเลย
    else endpoint ไม่อยู่ใน Coverage
        M->>H: ผ่าน — OptIn และไม่ได้แปะ RequireClientSignature
    else endpoint อยู่ใน scope
        M->>M: ไม่มี X-Signature หรือ X-Timestamp — SIG_MISSING_HEADER
        M->>M: timestamp parse ไม่ได้ หรือ skew เกิน 300 วินาที — SIG_TIMESTAMP_INVALID
        M->>M: ลายเซ็น parse ไม่ได้ — SIG_MALFORMED
        M->>M: ไม่มี oid ใน ClaimsPrincipal — SIG_KEY_NOT_FOUND
        M->>R: อ่าน public key ของ oid ที่ prefix จาก KeyRegistryPrefix
        alt Redis ตอบไม่ได้
            R--xM: SIG_KEY_STORE_UNAVAILABLE
        else ไม่มีกุญแจของผู้ใช้คนนี้
            R-->>M: null — SIG_KEY_NOT_FOUND
        else มีกุญแจ
            R-->>M: public key ECDSA P-256
            M->>M: body ต้องอ่านซ้ำได้ ไม่งั้น SIG_BODY_UNREADABLE
            M->>M: ประกอบ stringToSign ฝั่ง server เอง แล้วเทียบลายเซ็น — ไม่ตรง = SIG_INVALID
            M->>R: จดลายนิ้วมือของคำขอ TTL 600 วินาที
            alt เคยเห็นลายนิ้วมือนี้แล้ว
                R-->>M: SIG_REPLAY
            else ยังไม่เคยเห็น
                R-->>M: จดสำเร็จ
                M->>R: ต่ออายุกุญแจของผู้ใช้อีก 7 วัน แบบไม่รอผล
                M->>H: ปล่อยเข้า handler
            end
        end
    end
    Note over M,H: ทุกทางที่ไม่ผ่านเขียน log หนึ่งบรรทัด [ClientSignature] code oid path skew mode<br/>Mode LogOnly ปล่อยเข้า handler ต่อ · Mode Enforce ตอบ 401 ProblemDetails

1. package

<PackageReference Include="SupApp_util_lib" Version="10.30.0" />

📕 กับดัก A-1 — ข้อกำหนด TFM · restore ไม่ผ่านที่ feed · สิ่งที่ต้องทำเพิ่มถ้า service นี้เปิด A/E/F อยู่แล้วบนเวอร์ชันเก่า

2. DI — 2 บรรทัด

🔴 หาไฟล์ของตัวเองก่อน อย่าเปิด Program.cs แล้วแปะ — หลาย service ไม่ได้ wire DI/pipeline ใน Program.cs แต่มี extension ของตัวเอง (AddAppSecurity() / UseAppPipeline() ใน {Service}01.API/Extensions/) แล้ว Program.cs เรียกตัวนั้นบรรทัดเดียว · แปะผิดที่ = ลำดับ middleware เพี้ยนหรือ DI ซ้ำ

# หาว่า DI ของ security อยู่ไฟล์ไหน
grep -rn "AddAuthentication\|AddAuthorization(" src/{Service}01.API/
# หาว่า pipeline อยู่ไฟล์ไหน และ UseAuthorization() อยู่บรรทัดไหน
grep -rn "UseAuthorization()" src/{Service}01.API/

บรรทัดข้างล่างวางในไฟล์ที่ grep ชี้ ไม่ใช่ Program.cs เสมอไป

// อ่านค่า ClientSignature จาก config เข้า options ของ middleware
services.AddClientSignature(o => configuration.GetSection("ClientSignature").Bind(o));

// ตัวอ่าน public key ของผู้ใช้จาก Redis (ที่ Sentinel เขียนไว้ตอนลงทะเบียน) + ตัวกันยิงซ้ำ
services.AddClientSignatureStores(configuration);

3. Pipeline — 1 บรรทัด ต่อท้าย UseAuthorization()

วางต่อจาก UseAuthorization() ที่มีอยู่แล้วในไฟล์ของ service เอง — บล็อกข้างล่างเป็นรูปทั่วไป ไม่ใช่สิ่งที่ต้อง copy ทั้งก้อน

app.UseAuthentication();
app.UseRedisUserInfo();
app.UseAuthorization();

app.UseClientSignatureVerification();   // เพิ่ม

ถ้าจะทำ B ด้วย ให้เพิ่ม app.UsePayloadFieldDecryption(); ต่อท้ายบรรทัดนี้ทันที · D เพิ่ม app.UsePayloadWholeBodyEncryption(); ต่อท้ายอีกที (สลับกับ B ได้ ไม่มีผล) · F วาง app.UseRequestLogCapture(); ไว้ก่อนสุด และ app.UseRequestLogBodyCapture(); ไว้ท้ายสุด · E ไม่มีบรรทัด pipeline เลย มีแต่ DI

flowchart LR
    R[UseRequestLogCapture] --> A[UseAuthentication]
    A --> B[UseRedisUserInfo]
    B --> C[UseAuthorization]
    C --> D[UseClientSignatureVerification]
    D --> E[UsePayloadFieldDecryption]
    E --> G[UsePayloadWholeBodyEncryption]
    G --> L[UseRequestLogBodyCapture]
    L --> F[Handler]

    style D fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332
    style E fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332
    style G fill:#d8f3dc,stroke:#2d6a4f,color:#1b4332
    style R fill:#e9f5ec,stroke:#74c69d,color:#1b4332
    style L fill:#e9f5ec,stroke:#74c69d,color:#1b4332

UseRequestLogCapture / UseRequestLogBodyCapture เพิ่มเฉพาะเมื่อทำ F · UsePayloadFieldDecryption เฉพาะ B · UsePayloadWholeBodyEncryption เฉพาะ D

ต้องมี EnableBuffering() อยู่ก่อน middleware นี้ — ไม่มี = 401 ทุก request ที่เซ็น

🔴 ถ้า service มี EnableBuffering() ของตัวเองอยู่แล้ว ตัวนั้นชนะ UseRequestLogCapture() ของ FEnableBuffering ของ ASP.NET Core ทำงานเฉพาะเมื่อ Request.Body.CanSeek == falseตัวที่รันก่อนคือตัวที่กำหนด threshold จริง ตัวที่รันทีหลังกลายเป็น no-op เงียบ ๆ · วาง UseRequestLogCapture() ไว้หลัง EnableBuffering() เดิม = RequestLog:RequestBufferThresholdBytes เป็น key ตาย ตั้งเท่าไหร่ก็ไม่มีผล (ได้ threshold ตั้งต้นของ framework แทน) · อย่าถอด EnableBuffering() เดิมออก เพื่อ “ให้ F ตั้งค่าได้” — F เรียกให้เฉพาะตอน RequestLog:Enabled=true ปิด flag เมื่อไหร่ A พังทันที

📕 กับดัก A-3 — วิธี grep หา EnableBuffering และเติมเอง · เกณฑ์ตำแหน่งที่แท้จริง · ทำไมสลับกับ B ไม่ได้ · กับดัก F — ลำดับของ EnableBuffering สองตัว

4. appsettings

ใส่ใน base appsettings.json (ไฟล์นี้ไม่ถูก IaC overlay ทับ)

"ClientSignature": {
  // สวิตช์หลักของ middleware ตรวจลายเซ็น
  //   false = ไม่ตรวจอะไรเลย request ผ่านตามปกติ (ค่าตั้งต้น)
  //   true  = เริ่มตรวจ · จะทำอะไรตอนตรวจไม่ผ่าน ขึ้นกับ Mode ข้างล่าง
  "Enabled": false,

  // ทำอะไรเมื่อลายเซ็นผิดหรือไม่มี — มีผลเมื่อ Enabled = true เท่านั้น
  //   "LogOnly" = ตรวจแล้วเขียน log ไว้ แต่ยังปล่อยผ่าน
  //   "Enforce" = ตอบ 401 จริง
  "Mode": "LogOnly",

  // ตรวจเส้นไหน — ตัวนี้สำคัญเท่า Enabled
  //   "OptIn"      = ตรวจเฉพาะ action ที่แปะ [RequireClientSignature] (ค่าตั้งต้น)
  //   "RequireAll" = ตรวจทุก action ยกเว้นที่แปะ [SkipClientSignature]
  "Coverage": "OptIn",

  // ใส่รายละเอียดว่าทำไมลายเซ็นไม่ผ่านลงใน response — เปิดเฉพาะตอน debug บนเครื่องตัวเอง
  "DebugResponse": false,

  // prefix ของ key ใน Redis ที่ Sentinel ใช้ตอนเก็บกุญแจของผู้ใช้
  // ต้องตรงกับที่ Sentinel เขียน ไม่งั้นหากุญแจไม่เจอ = 401
  // ไม่ตั้ง key นี้เลย = lib fallback ไปใช้ ServiceIdentity:ServiceName (คนละที่กับที่ Sentinel เขียน)
  "KeyRegistryPrefix": "SuperAppPlatform"
}

ไม่ตั้ง Coverage = OptIn ⇒ เปิด Enabled=true แล้วยังไม่มีเส้นไหนถูกตรวจจนกว่าจะแปะ [RequireClientSignature]

🔴 KeyRegistryPrefix ต้องตรงกับที่ Sentinel เขียน — ค่าที่ใช้อยู่คือ SuperAppPlatform (ตรงกันทั้ง SentinelGateway01.API/appsettings.json และ user-service) · ไม่ตั้ง key นี้ = lib fallback ไปใช้ ServiceIdentity:ServiceName ซึ่งคนละที่กับที่ Sentinel เขียน ⇒ หากุญแจไม่เจอ = 401 ทุก request ที่เซ็น

📕 กับดัก A-4 — ต้อง restart ถึงจะมีผล · KeyRegistryPrefix ผิด = 401 ทั้งหมด · กติกา parity กับ IaC

5. เลือกเส้น — [RequireClientSignature] ก่อน แล้วค่อยไปถึง RequireAll

ขั้นนี้ทำคนละแบบตาม Coverage ที่ตั้งไว้ อย่าทำงานของอีกโหมด

Coverage = OptIn (ค่าตั้งต้น · โหมดที่ใช้อยู่ตอนนี้)

ไม่ต้องไล่ inventory ทั้ง repo — เส้นที่ไม่แปะอะไรเลยไม่ถูกแตะ ⇒ งานคือ เลือกเส้นที่จะทดสอบ แล้วแปะ [RequireClientSignature] เฉพาะเส้นนั้น

[HttpGet("notice")]
[RequireClientSignature]   // ตรวจลายเซ็นเฉพาะเส้นนี้
public async Task<IActionResult> GetNotice([FromQuery] string id, CancellationToken ct) => ...

เกณฑ์เลือกเส้นแรก: read-only · ไม่ผูกกับผู้ใช้คนใดคนหนึ่ง · ไม่เขียนอะไรลงระบบ ⇒ ทดสอบพังก็ไม่เสียข้อมูล · อย่าเริ่มที่ write path

เปิด Enabled=true + Mode=LogOnly แล้วยิงเส้นนั้นด้วย request ที่เซ็นจริง → อ่าน log → Enforce เฉพาะเมื่อผ่านแล้ว · เส้นอื่นทั้ง service ยังเหมือนเดิมทุกอย่างตลอดกระบวนการนี้

Coverage = RequireAll (ปลายทาง — ทำเมื่อจะบังคับทั้ง service)

โหมดนี้ตรวจ ทุก action อัตโนมัติ ⇒ ต้องไล่ติด [SkipClientSignature] ให้ครบทุกเส้นที่ตรวจไม่ได้ ก่อนสลับ · ทุก action ตกอยู่ใน 4 แบบนี้แบบใดแบบหนึ่ง

แบบattribute ที่ต้องมีใช้กับ
1. ไม่ต้อง login[AllowAnonymous] + [SkipClientSignature]ข้อมูลทั่วไปที่เว็บดึงก่อน login · job/batch ข้างในมาเรียก · LIFF
2. login แต่ไม่ตรวจลายเซ็น[Authorize] + [SkipClientSignature]hub เรียกต่อหลังบ้าน (เช่น orchestrator ยิงต่อไป service อื่นโดยส่ง JWT ของผู้ใช้ไปด้วย) — หลังบ้านตรวจแค่ authorization ก็พอ
3. login และตรวจลายเซ็น[Authorize] เฉย ๆclient ยิงตรงเข้า service นี้ — ค่าตั้งต้นของโหมดนี้ ไม่ต้องใส่อะไรเพิ่ม
4. ไม่ login แต่ตรวจลายเซ็นยังไม่รองรับthird party ข้างนอกมาเรียกเรา
// แบบ 1
[HttpGet("active-ids")]
[AllowAnonymous]
[SkipClientSignature] // s2s: job ภายในเรียก ไม่มี user JWT ติดมาด้วย
public async Task<IActionResult> GetActiveIds(CancellationToken ct) => ...

// แบบ 2
[HttpPost("dispatch")]
[Authorize]
[SkipClientSignature] // orchestrator ส่ง JWT ของผู้ใช้ต่อมา แต่เซ็นแทนผู้ใช้ไม่ได้
public async Task<IActionResult> Dispatch(DispatchRequest req, CancellationToken ct) => ...

endpoint ที่ไม่มีทั้ง [Authorize] และ [AllowAnonymous] ตกเป็นแบบ 3 โดยอัตโนมัติ — นี่คือกองที่ทำให้พังจริงตอนสลับ RequireAll + Enforce ไล่ให้ครบทุกตัว · ตารางนี้ทั้งตารางไม่ใช้กับ OptIn — ใน OptIn เส้นแบบ 3 ไม่ถูกตรวจจนกว่าจะแปะ [RequireClientSignature]

route เดียวที่รับทั้ง client และ s2s แยกไม่ได้ด้วย attribute — ต้อง เพิ่ม route ใหม่ให้ s2s ชี้เข้า handler เดิม ติด [SkipClientSignature] เฉพาะเส้นใหม่ แล้วปิดไม่ให้เรียกจากภายนอก ห้ามติดทับเส้นเดิม

📕 กับดัก A-5 — attribute ไม่สืบทอด · กองที่ 3 คือตัวที่ทำให้พังจริง · ขั้นตอนเมื่อตัดสินเองไม่ได้ · แยก route ให้ s2s ต้องทำคู่กับอะไร · เส้นก่อน login ไม่ใช่ s2s


เช็คว่า A ติดตั้งสำเร็จ

เช็คผลที่ถูก
bootไม่มี DI error · /health = 200
ปิด flag ทั้งหมดแล้วรัน test เดิมผ่านเท่าเดิมทุกตัว — ถ้าตกแปลว่า wire ผิด ไม่ใช่เรื่อง flag
เปิด LogOnly แล้วยิงของเดิมทุกอย่างยังผ่าน แต่มี log บอกว่าเส้นไหนลายเซ็นไม่ผ่าน
Coverage=OptIn · ยิงเส้นที่ไม่ได้แปะ [RequireClientSignature] โดยไม่เซ็น ตอน Enforce200 ตามปกติ — ถ้าได้ 401 แปลว่าตั้ง Coverage ผิดหรือแปะ attribute ผิดเส้น
ยิงเส้นที่แปะ [RequireClientSignature] โดยไม่เซ็น ตอน Mode=Enforce401 SIG_MISSING_HEADER
ยิงซ้ำด้วยลายเซ็นเดิม401 (replay ถูกกัน)
กุญแจของผู้ใช้ใน Redisมี key SuperAppPlatform:cache:client-key:<oid> หลังเบราว์เซอร์ลงทะเบียนแล้ว

📕 กับดักตอนเช็ค — service ที่ config ว่างต้องต่อ KV ก่อน · เอา request ที่เซ็นจริงมาจากไหน

ลำดับที่แนะนำสำหรับ A

  1. bump package · build ให้ผ่าน ยังไม่เปิด flag อะไรเลย
  2. wire DI + pipeline ในไฟล์ของ service เอง · boot · รัน test เดิมต้องผ่านเท่าเดิม
  3. ใส่ config ครบทุก key รวม "Coverage": "OptIn" โดย Enabled ยังเป็น false
  4. แปะ [RequireClientSignature] เส้นเดียว ที่ read-only แล้วเปิด Enabled=true + Mode=LogOnly บน dev → ยิงเส้นนั้นด้วย request ที่เซ็นจริง → อ่าน log
  5. ผ่านแล้วค่อย Mode=Enforce บนเส้นนั้น → ทยอยแปะเส้นถัดไป
  6. ปลายทาง Coverage=RequireAll — ทำเมื่อไล่ [SkipClientSignature] ครบทั้ง repo แล้วเท่านั้น (ดูขั้น 5 หัวข้อ RequireAll) เป็นงานแยกรอบ ไม่ใช่ขั้นตอนต่อเนื่องจากข้อ 5
  7. B / C / D / E / F ทำแยกทีหลัง — คนละสวิตช์ คนละความเสี่ยง ไม่มีตัวไหนบังคับให้ทำพร้อมกัน (ยกเว้น D ที่ต้องมี B ก่อน)