Private Docs

Body Signature — ภาพระบบหลัง Implement

Target architecture ของ body signature ทั้งขา browser (WebCrypto keypair) และขา service-to-service (HMAC) พร้อม sequence diagram ทั้งระบบ

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

Body Signature — ภาพระบบหลัง Implement (Target Architecture)

เอกสารนี้ตอบคำถามเดียว: “ถ้าทำ body signature เสร็จแล้ว ระบบเราจะเป็นแบบไหน” — เป็นภาพเป้าหมาย ยังไม่ได้ implement

มติ Owner 31/07/2026: POC = FE → Codex · ลงทะเบียน key แบบ TOFU first-key-wins · ครอบ GET ด้วย · field encryption ตัดไปรอบหน้า · CSP/Trusted Types เป็น track แยก — รายละเอียดในข้อ 9-10

⚠️ ข้อความที่เอาไปเซ็นถูกแก้หลัง security review — ของจริงที่จะ implement คือ v1\n{ts}\n{METHOD}\n{path}\n{query}\n{base64(sha256(body))} (คั่นด้วยขึ้นบรรทัดใหม่ ไม่ใช่จุด) และ ไม่มี X-Key-Id · เอกสารนี้เขียนก่อนหน้านั้น ให้ยึด แผน Implement ข้อ 3 เป็นตัวจริง

อ้างอิงงานเดิม: IMPL PLAN 02 — Data in Transit · DECISION REGISTER (ข้อ B1/B2) · key rotation ดู IMPL PLAN 06

สรุป 5 บรรทัด

  • ระบบจะมี signature 2 ชนิด ที่แยกกันชัดเจน: ขา browser → backend ใช้ กุญแจคู่ (keypair) · ขา service → service ใช้ กุญแจร่วม (HMAC) ตามของที่ Backend_Package มีอยู่แล้ว
  • ขา browser ไม่มี secret เดินทางในเน็ตเวิร์กเลย — browser สร้างกุญแจคู่เอง ส่งขึ้นไปเฉพาะ public key
  • private key อยู่ใน browser แบบ non-extractable — JavaScript สั่งให้ “เซ็น” ได้ แต่ “ขอดูค่ากุญแจ” ไม่ได้ ⇒ ขโมยออกไปใช้ที่เครื่องอื่นไม่ได้
  • Cloudflare (CDN/WAF) และ APIM ไม่ต้องรู้จักกุญแจเลย ทำหน้าที่ส่งผ่าน header ตามปกติ
  • ปลายทางที่ verify คือ backend service ที่ติด [RequireSignature] เท่านั้น

1. ปูพื้น: กุญแจคู่ต่างจากกุญแจร่วมยังไง

1.1 กุญแจร่วม (HMAC) — ที่ Backend_Package ใช้อยู่

คนส่ง:  signature = HMAC(กุญแจ K, "{timestamp}.{body}")
คนรับ:  คำนวณซ้ำด้วยกุญแจ K ตัวเดียวกัน แล้วเทียบ

กุญแจดอกเดียวกันทั้งสองฝั่ง ⇒ ใครก็ตามที่มี K จะ ตรวจ ก็ได้ ปลอม ก็ได้ — เหมาะกับ server คุย server (ทั้งคู่เก็บ K ใน Key Vault ได้) แต่ใช้กับ browser ไม่ได้ เพราะถ้าเอา K ใส่ browser = ทุกคนที่เปิด DevTools ได้ K ไปปลอม request

1.2 กุญแจคู่ (keypair) — ที่จะใช้กับ browser

กุญแจเกิดมาเป็น คู่ ที่จับคู่กันทางคณิตศาสตร์:

กุญแจอยู่ที่ไหนทำอะไรได้
private keyใน browser ของผู้ใช้เท่านั้น ไม่เคยออกจากเครื่องเซ็น ข้อความ
public keyส่งขึ้นไปเก็บที่ backend (Redis)ตรวจลายเซ็น ได้อย่างเดียว — ปลอมลายเซ็นไม่ได้

หัวใจคือ: public key ไม่ใช่ความลับ หลุดไปก็ทำอะไรไม่ได้นอกจากตรวจ ⇒ ปัญหา “จะเก็บ secret ไว้ที่ FE ยังไง” หายไปทั้งข้อ เพราะไม่มี secret ให้เก็บ

1.3 extractable: false คืออะไร — จุดที่ทำให้ปลอดภัยจริง

const keyPair = await crypto.subtle.generateKey(
  { name: 'ECDSA', namedCurve: 'P-256' },
  false,                    // ← extractable = false
  ['sign', 'verify']
);

extractable: false แปลว่า private key ถูกเก็บอยู่ใน crypto subsystem ของ browser และ ไม่มี API ใดเอาค่ามันออกมาได้อีกเลยexportKey() จะ throw ทันที

เทียบให้เห็นภาพ: เหมือนตู้เซฟที่ ยื่นเอกสารเข้าไปให้ประทับตราได้ แต่ หยิบตราประทับออกมาไม่ได้

ผลที่ได้จริง:

สถานการณ์HMAC key เก็บใน memory/localStoragenon-extractable private key
XSS อ่านค่า key แล้วส่งออกไปเครื่อง attacker❌ ทำได้✅ ทำไม่ได้ (export ไม่ออก)
XSS สั่งเซ็น request ขณะเหยื่อเปิดหน้าอยู่❌ ทำได้❌ ทำได้ (จำกัดอยู่แค่ตอนที่เหยื่อยังเปิดหน้า)
refresh หน้าแล้วยังใช้ต่อได้ไหมต้องขอ key ใหม่ทุกครั้ง✅ เก็บลง IndexedDB ได้ และยังคง non-extractable

2. ภาพรวมระบบหลัง implement

graph TB
    subgraph browser["Browser ของผู้ใช้"]
        SPA["Angular Shell<br/>(Frontend_HostAppSuperApp)"]
        SDK["@exim/auth-sdk<br/>bodySignatureInterceptor<br/>(Frontend_SuperAppLibraryUi)"]
        IDB[("IndexedDB<br/>CryptoKey handle<br/>non-extractable")]
        SPA --> SDK
        SDK -.เก็บ/อ่าน handle.-> IDB
    end

    subgraph edge["ขอบนอก"]
        CF["Cloudflare<br/>CDN + WAF<br/>(ไม่รู้จักกุญแจ)"]
        APIM["Azure APIM<br/>sentinel-facade policy<br/>cookie → JWT<br/>(ส่งผ่าน header signature ตามปกติ)"]
    end

    subgraph backend["Backend (AKS)"]
        SG["Sentinel Gateway<br/>เจ้าของ session"]
        BE["Backend Service ปลายทาง<br/>เช่น Codex / UserService<br/>[RequireSignature]"]
        PKG["SupApp_util_lib<br/>ISignatureService<br/>+ ISignatureKeyResolver ใหม่"]
        BE --- PKG
    end

    subgraph state["State"]
        REDIS[("Redis<br/>session + public key<br/>ต่อ user/session")]
        KV[("Key Vault<br/>HMAC key สำหรับ S2S")]
    end

    SDK -->|"HTTPS + cookie<br/>+ X-Signature / X-Timestamp / X-Key-Id"| CF
    CF --> APIM
    APIM -->|resolve-session| SG
    SG --> REDIS
    APIM -->|"forward + Bearer JWT<br/>+ signature headers"| BE
    BE -->|ดึง public key ของ user นี้| REDIS
    PKG -->|HMAC key ขา S2S| KV

    OTHER["Service อื่น (caller)<br/>SignedHttpClient"] -->|"HMAC signature"| BE

สิ่งที่ต้องสังเกต 3 ข้อ

  1. Cloudflare กับ APIM ไม่แตะกุญแจเลย — แค่ส่งผ่าน header (X-Signature, X-Timestamp, X-Key-Id) เหมือน header ทั่วไป ไม่ต้องแก้ policy ถ้าไม่ต้องการให้ APIM ปฏิเสธตั้งแต่ขอบ
  2. flow auth เดิมไม่เปลี่ยนเลย — cookie → APIM → resolve-session ที่ Sentinel → Bearer JWT ยังทำงานเหมือนเดิมทุกประการ signature เป็นชั้นที่ เพิ่มมาข้างบน ไม่ได้แทนที่อะไร
  3. มี signature 2 ชนิดในระบบเดียวกัน — ขา browser (keypair) กับขา service→service (HMAC) ทั้งคู่ verify ผ่าน abstraction เดียวใน Backend_Package

3. Flow ที่ 1 — Login (ของเดิม ไม่เปลี่ยน)

sequenceDiagram
    autonumber
    participant U as ผู้ใช้
    participant SPA as Angular Shell
    participant APIM as APIM
    participant SG as Sentinel Gateway
    participant EID as Entra ID / CIAM
    participant R as Redis

    U->>SPA: เข้าเว็บ กด login
    SPA->>APIM: GET /sentinel/login (passthrough ไม่ resolve)
    APIM->>SG: forward
    SG->>EID: OIDC redirect
    EID-->>SG: callback + token
    SG->>R: เก็บ access/refresh/id token (server-side token store)
    SG-->>SPA: Set-Cookie (.Exim.Auth*) = ตัวชี้ session ไม่มี token อยู่ข้างใน
    Note over SPA: จบขั้นนี้ FE มีแค่ cookie เหมือนเดิมทุกอย่าง

4. Flow ที่ 2 — สร้างกุญแจและลงทะเบียน (ใหม่ · ทำครั้งเดียวต่อเครื่อง)

sequenceDiagram
    autonumber
    participant SPA as Angular Shell + auth-sdk
    participant IDB as IndexedDB
    participant APIM as APIM
    participant SG as Sentinel
    participant BE as Backend Service
    participant R as Redis

    Note over SPA,IDB: หลัง login สำเร็จ
    SPA->>IDB: มี CryptoKey เก็บไว้แล้วหรือยัง?
    alt ยังไม่มี (เครื่องใหม่ / เพิ่ง clear)
        SPA->>SPA: crypto.subtle.generateKey(ECDSA P-256, extractable:false)
        SPA->>IDB: เก็บ handle ของ keypair (ค่ากุญแจอ่านไม่ได้)
        SPA->>SPA: exportKey('jwk', publicKey) — public key export ได้ปกติ
        SPA->>APIM: POST /api/.../v1/client-keys { publicKeyJwk, alg } + cookie
        APIM->>SG: resolve-session (cookie → JWT)
        SG-->>APIM: accessToken ของ session นี้
        APIM->>BE: forward + Bearer JWT
        BE->>BE: อ่าน oid/sessionId จาก JWT
        alt user นี้ยังไม่มี key (ครั้งแรก)
            BE->>R: SET client-key:{oid}:{kid} = publicKeyJwk (TTL = อายุ session)
            BE-->>SPA: 200 { kid }
            SPA->>IDB: เก็บ kid คู่กับ keypair
        else user นี้มี key อยู่แล้ว — TOFU first-key-wins
            BE->>BE: ปฏิเสธ + log + alert (สัญญาณ cookie-theft)
            BE-->>SPA: 409 KeyAlreadyRegistered
            Note over SPA: ทางออกเดียว = บังคับ re-login<br/>(session ใหม่ → register ใหม่ได้)
        end
    else มีอยู่แล้ว
        SPA->>SPA: ใช้ handle เดิม ไม่ต้อง register ซ้ำ
    end

ประเด็นสำคัญ 2 ข้อ:

  • ขั้นนี้เป็นครั้งเดียวที่มี “การลงทะเบียน” และสิ่งที่ส่งขึ้นไปคือ public key ล้วนๆ — หลุดกลางทางก็ไม่มีผลอะไร
  • TOFU (Trust On First Use) + first-key-wins: session หนึ่งลงทะเบียน key ได้ครั้งเดียว — ใครมาขอ register ซ้ำ (เช่น คนขโมย cookie ไปแล้วพยายามใส่กุญแจตัวเอง) จะโดนปฏิเสธ และความพยายามนั้นกลายเป็น สัญญาณตรวจจับ cookie-theft ให้ฟรีๆ · ผู้ใช้จริงที่ clear browser data ต้อง re-login เพื่อได้ session ใหม่ก่อนถึงจะ register ใหม่ได้

5. Flow ที่ 3 — ยิง request ที่มีลายเซ็น (ภาพหลัก)

sequenceDiagram
    autonumber
    participant SPA as Angular Shell
    participant INT as bodySignatureInterceptor
    participant IDB as IndexedDB
    participant CF as Cloudflare CDN/WAF
    participant APIM as APIM facade
    participant SG as Sentinel
    participant BE as Backend Service
    participant R as Redis

    SPA->>INT: GET/POST/PUT/PATCH /api/.../v1/xxx
    Note over INT: ทำงานเฉพาะ URL ที่ config บอกว่าต้องเซ็น<br/>ครอบทั้ง write และ GET (มติ 31/07)
    INT->>INT: body = JSON.stringify(payload) — GET ใช้ string ว่าง
    INT->>INT: ts = Math.floor(Date.now()/1000)
    INT->>IDB: ขอ privateKey handle + kid
    IDB-->>INT: CryptoKey (ค่ากุญแจอ่านไม่ได้)
    INT->>INT: sig = crypto.subtle.sign('ECDSA', privateKey, "{ts}.{method}.{path+query}.{body}")
    INT->>CF: ส่ง request + cookie<br/>X-Signature / X-Timestamp / X-Key-Id

    CF->>APIM: ส่งผ่าน (CDN/WAF ไม่ยุ่งกับ header เหล่านี้)
    APIM->>SG: POST /sentinel/internal/resolve-session (cookie)
    SG-->>APIM: accessToken + isValid (cache 120s ตามเดิม)
    APIM->>BE: forward + Authorization Bearer JWT + signature headers ครบ

    BE->>BE: JWT validation ตามปกติ (Entra JWKS)
    Note over BE: SignatureValidationFilter เริ่มทำงาน<br/>(SkipGetRequests = false — GET ก็ตรวจ)
    BE->>BE: 1) มี X-Signature / X-Timestamp ครบไหม
    BE->>BE: 2) timestamp อยู่ในกรอบ ±300 วิ ไหม
    BE->>R: 3) GET client-key:{oid}:{kid}
    alt ไม่พบ public key
        BE-->>SPA: 401 SignatureValidationError (Unknown Key)
    else พบ
        BE->>BE: 4) ประกอบ "{ts}.{method}.{path+query}.{body}" (GET = body ว่าง)<br/>แล้ว verify ลายเซ็นด้วย public key
        alt ลายเซ็นถูก
            BE->>BE: เข้า handler ทำงานปกติ
            BE-->>SPA: 200
        else ลายเซ็นผิด / body ถูกแก้
            BE-->>SPA: 401 SignatureValidationError (Invalid Signature)
        end
    end

จุดที่พังง่ายที่สุด (ต้องระวังตอน implement)

ความเสี่ยงอาการวิธีคุม
body ที่เซ็น ≠ body ที่ส่งจริง401 ทุก request แบบไม่มีเหตุผลเซ็นจาก string ตัวเดียวกับที่ใส่ลง req.clone({ body }) เท่านั้น ห้าม serialize ซ้ำ
interceptor ทำงานกับ request ที่ไม่ควรเซ็นendpoint เดิมพัง 401 ยกแผงwhitelist URL + method ที่ต้องเซ็นเท่านั้น ค่า default = ไม่เซ็น
นาฬิกาเครื่องผู้ใช้เพี้ยน401 Expired Request เฉพาะบางเครื่องtolerance 300 วิ + ให้ BE ส่งเวลาปัจจุบันกลับมาใน error ให้ FE ชดเชย
เปิด [RequireSignature] ก่อน FE พร้อมผู้ใช้ทำงานไม่ได้ทันทีเปิดแบบ log-only ก่อน (วัดว่ามี signature กี่ % ของ traffic) → ค่อยบังคับ
ครอบ GET แต่ endpoint นั้นถูกเรียกก่อน loginanonymous call ไม่มี key ให้เซ็น → 401endpoint ที่ต้องใช้ก่อน login (AllowAnonymous) ห้ามอยู่ใน whitelist บังคับ signature

6. Flow ที่ 4 — refresh หน้า / เปิด tab ใหม่ / logout

sequenceDiagram
    autonumber
    participant SPA as Angular Shell
    participant IDB as IndexedDB
    participant BE as Backend Service
    participant R as Redis

    Note over SPA: refresh หน้า หรือเปิด tab ใหม่
    SPA->>IDB: อ่าน keypair handle + kid
    IDB-->>SPA: ได้ handle เดิม (ยัง non-extractable)
    SPA->>SPA: เซ็นต่อได้เลย ไม่ต้อง register ใหม่

    Note over SPA,R: กรณี logout
    SPA->>BE: POST /sentinel/logout (flow เดิม)
    BE->>R: ลบ session + ลบ client-key ของ user
    SPA->>IDB: ลบ keypair handle ทิ้ง
    Note over SPA: เข้าใหม่ = สร้างกุญแจใหม่ ลงทะเบียนใหม่

7. Flow ที่ 5 — ขา service → service (HMAC ของเดิม)

อีกครึ่งของงานนี้ ใช้ของที่ Backend_Package มีครบแล้ว ไม่ต้องเขียนใหม่:

sequenceDiagram
    autonumber
    participant A as Service A (caller)
    participant KV as Key Vault
    participant B as Service B [RequireSignature]

    Note over A,B: ตอน startup ทั้งคู่โหลด shared key เดียวกัน
    A->>KV: SignatureValidation:SecretKey
    B->>KV: key เดียวกัน (แยกต่อ "ความสัมพันธ์" ไม่ใช่ key เดียวทั้ง platform)

    A->>A: sig = HMAC-SHA256(K, "{ts}.{body}")
    A->>B: POST + X-Signature + X-Timestamp (ผ่าน SignedHttpClient)
    B->>B: ตรวจ timestamp → คำนวณ HMAC ซ้ำ → FixedTimeEquals
    alt ถูก
        B-->>A: 200
    else ผิด/หมดอายุ
        B-->>A: 401 SignatureValidationError
    end

ทั้ง 2 ขาใช้ทางเข้าเดียวกันที่ฝั่งรับSignatureValidationFilter เรียก ISignatureKeyResolver แล้วมันตัดสินเองว่า request นี้ต้อง verify แบบ HMAC (ไม่มี X-Key-Id ⇒ S2S) หรือแบบ keypair (มี X-Key-Id ⇒ ดึง public key จาก Redis)

8. สรุปว่าแต่ละ repo เปลี่ยนอะไร

Repoเปลี่ยนอะไรของเดิมมีอยู่แล้วแค่ไหน
Backend_Packageเพิ่ม ISignatureKeyResolver (แยก “หา key ยังไง” ออกจาก “verify ยังไง”) + เพิ่มตัว verify แบบ ECDSA + ต่อเข้า SignatureValidationFilter เดิมHMAC + filter + middleware + SignedHttpClient มีครบแล้ว
Backend_CodexService (pilot)เปิด AddSignatureValidation + endpoint POST /v1/client-keys + ติด [RequireSignature] เฉพาะ endpoint นำร่อง + bump SupApp_util_lib 10.6.2 → ล่าสุดยังไม่มีอะไรเลย
Frontend_SuperAppLibraryUiเพิ่ม bodySignatureInterceptor + clientKey.service.ts (generate/เก็บ IndexedDB/register) ใน auth-sdk วางคู่ csrf.interceptor เดิมมี interceptor pattern + spec ครบให้เลียนแบบ
Frontend_HostAppSuperAppต่อ interceptor ตัวใหม่เข้า provider chain + config ว่า URL ไหนต้องเซ็นมี chain อยู่แล้ว
Backend_Iacเพิ่ม section SignatureValidation ใน appsettings ทุก env (parity) + KV secret สำหรับขา S2S · APIM policy ไม่ต้องแก้ ถ้าไม่บังคับ fail-fast ที่ขอบ
Backend_SentinelGatewayServiceไม่ต้องแตะในรอบนี้ — จะแตะก็ต่อเมื่อย้ายจุดผูก key ไปที่ session จริง (ดูข้อ 10)

9. พูดตรงๆ ว่ากันอะไรได้ กันอะไรไม่ได้ — และทางออกของแต่ละข้อ

ได้จริง

  • body/query ถูกแก้ระหว่างทาง (proxy, extension, MITM ที่ terminate TLS ได้) → ตรวจจับได้
  • replay request เก่า → หมดอายุใน 300 วินาที
  • private key ถูกขโมยออกไปใช้ที่เครื่องอื่น → ทำไม่ได้ (non-extractable)
  • cookie-theft บน endpoint ที่บังคับ signature — cookie ที่ถูกขโมยไปอย่างเดียว เซ็นไม่ได้ → ทั้งอ่าน (GET) และเขียนโดน 401 หมด · ความพยายาม register กุญแจใหม่ทับโดน TOFU ปฏิเสธ + กลายเป็น alert ตรวจจับ (เหลือหน้าต่างเดียว = ช่วงสั้นๆ หลัง login ก่อนเจ้าของ register — ปิดสนิทได้ตอนย้ายจุดผูก key ไป Sentinel ใน production phase)
  • ได้ audit ว่า request นี้มาจากเครื่องที่ลงทะเบียนไว้จริง (kid ผูกกับ user)

ไม่ได้ — และทางออกจริงอยู่ที่ไหน

ข้อจำกัดทำไม signature ช่วยไม่ได้ทางออกจริง
ไม่กัน XSSสคริปต์ที่รันในหน้าเดียวกันเรียก sign() ได้เท่าเทียมกับโค้ดเรา — browser แยกไม่ออกว่าใครเรียกCSP strict (nonce) + Trusted Types = ตัดไม่ให้สคริปต์แปลกปลอมเข้ามารันตั้งแต่ต้น (track แยก ตาม IMPL_PLAN_10) · อนาคต: WebAuthn step-up สำหรับ operation มูลค่าสูง (ลายเซ็นที่ต้องมีการกดของผู้ใช้จริง — primitive เดียวที่ XSS สั่งเงียบๆ ไม่ได้)
ไม่ได้เข้ารหัสsignature พิสูจน์ความถูกต้อง ไม่ได้ซ่อนเนื้อหาfield encryption (RSA-OAEP) เฉพาะ field อ่อนไหว — Owner ตัดออกจากรอบนี้ 31/07 ทำรอบหน้าแน่นอน หลัง PII inventory
ไม่ใช่ตัวแทน authไม่ใช่ gap — เป็นการประกาศขอบเขต: cookie (ใครเป็นคนยิง) + signature (ยิงจากเครื่องที่ลงทะเบียน + เนื้อหาไม่ถูกแก้) ทำงานซ้อนกันทุก request อยู่แล้ว เพราะ key ถูกผูกกับ oid จาก session ตั้งแต่ตอน register

10. ทะเบียนการตัดสินใจ (อัปเดต 31/07/2026)

#เรื่องมติ
1จุดลงทะเบียน public key(ก) Codex รับเอง + TOFU first-key-wins — POC ไม่แตะ Sentinel · ย้ายไป Sentinel (key เกิด/ตายพร้อม session, register ที่เดียวใช้ได้ทุก service) เป็น production phase
2ผูก method + path ในลายเซ็นผูก{ts}.{method}.{path+query}.{body} (ถูกบังคับโดยมติครอบ GET ในตัว: GET ไม่มี body ต้องมีอย่างอื่นให้เซ็น)
3ครอบ GET ไหมครอบ (มติ Owner 31/07) — GET เซ็นด้วย body ว่าง · ยกเว้น endpoint ที่ใช้ก่อน login
4endpoint นำร่อง⏳ เลือกตอน executive meeting จาก endpoint ของ Codex ที่มี caller จริง
5rollout✅ log-only ก่อนเสมอ (วัด % ของ traffic ที่มี signature) → ค่อยบังคับ
6field encryption✂️ ตัดออกจากรอบนี้ — ทำรอบหน้า (ตอน implement ให้วาง crypto service ฝั่ง FE เป็นไฟล์กลาง เผื่อเพิ่ม RSA-OAEP ได้โดยไม่รื้อ)