Body Signature & Encryption — คู่มือ Investigate สำหรับ Dev
เมื่อ request โดน 401/409 หรือถอดรหัสไม่ออก — dev ไล่ปัญหายังไงให้เจอใน 5 นาที: reason code, runbook อาการ→สาเหตุ, เครื่องมือเทียบสองฝั่ง และกติกาว่าอะไรห้าม log
อัปเดต: 2026-08-05
Body Signature & Encryption — คู่มือ Investigate สำหรับ Dev
ระบบ signature/encryption ที่ debug ไม่ได้ = ระบบที่ทีมจะขอปิดภายในเดือนแรก — เอกสารนี้ทำสองหน้าที่: (1) กำหนดของที่ต้อง build ลงไปตั้งแต่ POC เพื่อให้ไล่ปัญหาได้ (ไม่ใช่ของแถม — เป็น requirement) และ (2) เป็น runbook ตอนเจอปัญหาจริง
คู่กับ แผน Implement (wire contract ข้อ 3 = ตัวจริงเสมอ) และภาพระบบ
TL;DR — อ่าน 30 วิ
- ทุก 401/409 จากชั้น 2/3 ต้องมี reason code — dev อ่านแล้วรู้ทันทีว่าพังชั้นไหน ไม่ต้องเดา
- ปัญหาที่เจอบ่อยสุดของงานประเภทนี้คือ สองฝั่งประกอบ string_to_sign ไม่ตรงกัน — เครื่องมือหลักคือ debug response (non-prod) ที่ให้ฝั่ง server ตอบ canonical parts ของตัวเองกลับมาเทียบ
- log-only mode (มติ rollout: log ก่อนบังคับเสมอ) = เครื่องมือ investigate ฟรี — เห็นผล validate จริงบน traffic จริงโดยยังไม่ block ใคร
- kill-switch ปลด block ทั้ง env ได้ทันทีโดยไม่ redeploy — ใช้เมื่อ POC ทำ dev พัง
- กติกาเหล็ก: ห้าม log body ดิบ / ค่า signature เต็ม / private key / plaintext ของ field ที่เข้ารหัส — ทุก debug flag เปิดได้เฉพาะ non-prod
1. ของที่ต้อง build ตั้งแต่ POC (design-for-debuggability — ไม่ใช่ optional)
1.1 Reason code บน response ทุกตัวที่ปฏิเสธ
ClientSignatureValidationFilter ตอบ 401/409 พร้อม code ใน body (ProblemDetails) — ห้ามตอบ 401 เปล่าๆ เด็ดขาด เพราะ dev จะแยกไม่ออกเลยว่าตกชั้นไหน:
| Code | ความหมาย | ชี้ไปที่ |
|---|---|---|
SIG_MISSING_HEADER | ไม่มี X-Signature หรือ X-Timestamp | interceptor ไม่ทำงาน / URL ไม่อยู่ใน signing list (ดู runbook ข้อ 2.1) |
SIG_TIMESTAMP_INVALID | timestamp อ่านไม่ออก / ต่างจาก server เกิน 300 วิ | นาฬิกาเครื่อง / ts ถูก cache / retry ด้วย request เก่า |
SIG_KEY_NOT_FOUND | ไม่มี public key ของ oid นี้ใน Redis | ยังไม่ register / TTL หมด / คนละ env / oid ไม่ตรง |
SIG_INVALID | ลายเซ็นตรวจไม่ผ่าน | canonicalization mismatch เกือบทุกครั้ง — ไปข้อ 3.1 |
SIG_MALFORMED | signature ไม่ใช่ base64 / ไม่ใช่ 64 bytes / เป็น DER | FE เซ็นผิด format (ต้อง raw r‖s P1363) |
KEY_CONFLICT (409) | ส่ง JWK ใหม่ทับ key เดิม (TOFU) | ผู้ใช้ล้าง browser / เครื่องที่สอง — หรือสัญญาณ cookie-theft (ดู 2.4) |
ENC_* (ชั้น 3) | ดูข้อ 4 | — |
Log ฝั่ง server ต่อทุกการปฏิเสธ: oid + path + reason code + ts skew + sig length + correlation id — เท่านี้พอ ไล่ได้ทุกเคสโดยไม่แตะข้อมูลอ่อนไหว
1.2 Debug response — เฉพาะ non-prod (เครื่องมือเบอร์หนึ่ง)
config ClientSignature:DebugResponse (default false · guard ด้วย !IsProduction ในโค้ด — ต่อให้ config หลุดไป prod ก็ไม่ทำงาน): เมื่อเปิด, ทุก 401 SIG_INVALID จะแนบ canonical parts ที่ฝั่ง server ประกอบได้ กลับไปด้วย:
{
"code": "SIG_INVALID",
"debug": {
"ts": "1750000000",
"method": "POST",
"path": "/api/user-service/v1/client-keys/echo",
"query": "",
"bodyHashB64": "AVq9f1zFei3ZS3WQ8ErYCEJzkF7jPsXOvq5iJ2qX+GI=",
"bodyLength": 7
}
}
FE log ฝั่งตัวเองไว้แล้ว (ข้อ 1.4) → เทียบทีละบรรทัด — ช่องไหนไม่ตรง = จุดพังอยู่ตรงนั้น จบใน 1 นาที · ปลอดภัยเพราะทุกค่า derive จาก request ที่คนส่งเป็นเจ้าของเองอยู่แล้ว ไม่มี secret
1.3 Log-only mode (มาพร้อมมติ rollout ข้อ 5 อยู่แล้ว)
ClientSignature:Mode = "LogOnly" | "Enforce" — LogOnly ตรวจทุกอย่างจริง + log ผล (code เดียวกับ 1.1) แต่ปล่อยผ่านทุก request · ใช้ 2 จังหวะ: (ก) เปิดก่อน Enforce เสมอเพื่อดูของจริงบน traffic จริง (ข) เกิดปัญหาบน dev แล้วอยากเก็บข้อมูลโดยไม่ block เพื่อน → สลับกลับมา LogOnly แทนการปิด kill-switch ทิ้งเลย (ยังเห็น log ต่อ)
1.4 ฝั่ง FE — debug flag ใน ShareLib
- ตัวประกอบ string_to_sign เป็น pure function (ตามแผนข้อ 4) → มี jest test เทียบ frozen fixture ตลอดเวลา
- flag
bodySignatureDebug(dev build เท่านั้น): logstring_to_sign+ bodyHash + sig length ลง console ก่อนส่งทุก request — คู่เทียบกับ debug response ข้อ 1.2
1.5 Kill-switch
ClientSignature:Enabled=false (base appsettings — ทุก env ที่ไม่ใช่ prod) = ปิดการตรวจทั้งหมดทันที ไม่ต้อง redeploy · ใช้เมื่อ POC ทำ dev แตกและต้องปลด block คนอื่นก่อน แล้วค่อยกลับมาไล่ด้วย LogOnly
2. Runbook — อาการ → สาเหตุที่พบบ่อย → วิธีไล่
2.1 SIG_MISSING_HEADER — interceptor ไม่ยิง header
- เปิด DevTools → Network → ดู request จริงว่ามี
X-Signature/X-Timestampไหม - ไม่มี → เช็ค config URL ที่ต้องเซ็น — ⚠️ trap ที่เจอไว้แล้ว: shell ประกาศ config ซ้ำ 2 ที่ (
app.config.ts~บรรทัด 102-108 และ 119) ตัวหลังชนะ — ใส่ค่าผิดที่ interceptor จะเงียบสนิทโดย build เขียวปกติ - มีแต่โดน 401 อยู่ดี → header อาจถูก strip ระหว่างทาง — ยิงตรง service (ข้าม gateway) เทียบ ถ้ายิงตรงผ่านแต่ผ่าน gateway ไม่ผ่าน = ไปดู APIM/CORS (preflight ตก → browser ไม่ส่ง header — เช็ค
Cors:AllowedHeadersของ service)
2.2 SIG_TIMESTAMP_INVALID
- เทียบนาฬิกาเครื่อง client กับ server (
date/ response headerDate) — VM/container ที่ clock drift เจอบ่อย - FE ต้อง gen ts ใหม่ทุก request — retry logic ที่ reuse request เก่าทั้งก้อน (รวม header เดิม) จะตกข้อนี้เสมอ
2.3 SIG_KEY_NOT_FOUND
# non-prod เท่านั้น — ดูว่า key อยู่จริงไหม
redis-cli GET "{prefix}client-key:{oid}"
- ไม่มี key → FE ยัง register ไม่สำเร็จ (ดู Network ว่า
POST client-keysตอบอะไร) หรือ TTL หมดแล้ว (ต้อง register ใหม่) - มี key แต่ยังไม่เจอ → เช็คว่า oid ใน JWT ตรงกับที่ใช้ตอน register ไหม (คนละ tenant/คนละ account = คนละ oid) และ Redis ที่ service ต่อเป็น env เดียวกับที่ register (dev/sit ปนกันเจอมาแล้วหลายงาน)
2.4 KEY_CONFLICT (409)
- ผู้ใช้ล้าง browser data / เปิดเครื่องที่สอง = พฤติกรรมปกติของ TOFU — ทางออกตอน POC: ลบ key ใน Redis (non-prod) แล้ว register ใหม่ หรือรอ TTL
- ถ้าเจ้าของบอกว่าไม่ได้ทำอะไรเลย → treat เป็นสัญญาณ cookie-theft — เก็บ log ไว้ อย่าเพิ่งลบ key
2.5 SIG_INVALID — เคสยากสุด ไปข้อ 3
2.6 อาการที่ไม่ใช่ชั้น 2 — อย่าเสียเวลาไล่ผิดชั้น
| อาการ | จริงๆ คือ |
|---|---|
| 502 | ชั้น 1 — APIM resolve-session/Sentinel (ดู memory sentinel-resolve-session-401-masked-502) ไม่เกี่ยวกับ signature |
| 401 ที่ไม่มี reason code ของเรา | JWT validation ของ service เอง (ชั้น 1) ตกก่อนถึง filter เรา |
| CORS error ใน console | preflight — ไม่ใช่ signature ผิด (แต่เช็คตาม 2.1 ข้อ 3) |
| endpoint อื่นของ UserService พัง | ไม่เกี่ยวกับงานนี้โดยนิยาม — enforcement เป็น opt-in ที่ endpoint ทดสอบเท่านั้น ถ้า endpoint เดิมพังให้ถอน version lib ออกมาดูก่อนเลย |
3. เครื่องมือไล่ SIG_INVALID (canonicalization mismatch)
3.1 ลำดับการไล่ — จากถูกสุดไปแพงสุด
- รัน fixture ทั้งสองฝั่ง — frozen test vector (แผนข้อ 8) มี jest test (ShareLib pure function) และ .NET test ยึดอยู่ · ฝั่งไหนแดง = ฝั่งนั้นเพิ่งแก้อะไรพัง
- เปิด
DebugResponse(ข้อ 1.2) + FE debug flag (ข้อ 1.4) → เทียบ canonical parts ทีละช่อง — ช่องแรกที่ไม่ตรงคือคำตอบ - ช่องที่พังบ่อย เรียงตามสถิติของงานประเภทนี้:
| ช่อง | สาเหตุคลาสสิก |
|---|---|
bodyHash | BOM (server ต้อง hash raw bytes เอง ห้ามใช้ body ที่ middleware cache — contract ระบุแล้ว) · interceptor ตัวอื่น re-serialize body หลังเราเซ็น (ลำดับ interceptor!) · charset/encoding |
query | encode ไม่ตรง spec (%20 vs +) · ลำดับ sort · คีย์ซ้ำถูกยุบ |
path | trailing slash · path ผ่าน gateway ถูก rewrite ≠ path ที่ FE เซ็น (เทียบ Network tab กับ debug response) |
ts | FE เซ็นด้วย ts หนึ่ง แต่ส่ง header อีกค่า (gen สองครั้ง) |
3.2 ยิงเทียบด้วยมือ (ไม่มี browser)
curl เซ็นเองไม่ได้ (ไม่มี private key) — ใช้ fixture ที่เซ็นไว้แล้วจากแผนข้อ 8 ยิงตรง service local: ถ้า fixture ผ่านแต่ browser ไม่ผ่าน = ปัญหาอยู่ FE side / gateway ไม่ใช่ verifier
4. ชั้น 3 (encryption) — ไล่ยังไงเมื่อถอดไม่ออก
- JWE header อ่านได้เสมอโดยไม่ต้อง key (
alg/enc/kidเป็น base64url ไม่ใช่ secret) — decode ส่วนแรกดูก่อน:kidไม่ตรงกับ key ปัจจุบันของ server = ปัญหา rotation/cache (FE ถือ public key เก่า) ไม่ใช่ crypto พัง - reason codes:
ENC_MALFORMED_ENVELOPE(ไม่ใช่ JWE 5 ส่วน) ·ENC_UNKNOWN_KID(rotation) ·ENC_DECRYPT_FAILED(wrap/unwrap ไม่ตรง — เทียบ alg ทั้งสองฝั่ง) ·ENC_FIELD_NOT_ENCRYPTED(field ใน PII inventory ถูกส่ง plaintext — LogOnly ก่อนเสมอ) - ลำดับตรวจตายตัว verify-then-decrypt: ถ้าโดน
SIG_INVALIDก่อน ให้แก้ signature ให้จบก่อน — อย่าไล่ crypto สองชั้นพร้อมกัน - decrypt helper (non-prod เท่านั้น): dev tool/dotnet script ที่ใช้ private key non-prod จาก KV ถอด envelope ที่ก๊อปมาจาก Network tab — มีได้เฉพาะ non-prod, ห้ามมีเส้นทางแบบนี้ใน prod ไม่ว่ารูปแบบใด
- ฝั่ง response (เฟสถัดไป): ระหว่างที่ยังไม่ encrypt ขากลับ backend mask field อ่อนไหว — ถ้าหน้าจอแสดงค่า mask ทั้งที่ต้องใช้ค่าเต็ม = ไปแก้ mask rule ฝั่ง backend ไม่ใช่ bug FE
5. กติกาเหล็ก — investigate ได้แต่ห้ามเจาะรูระบบ
| ห้าม | เพราะ |
|---|---|
| log body ดิบ / plaintext ของ field ที่เข้ารหัส | ทำลายจุดประสงค์ชั้น 3 ทั้งชั้น — PII ไหลลง log ที่คนอ่านได้กว้างกว่า DB |
log ค่า X-Signature เต็ม | ใช้ replay ได้ภายใน 300 วิ — log แค่ความยาว + ผลตรวจพอ |
| log/export private key ทุกชนิด | — |
เปิด DebugResponse / debug flag / decrypt helper บน prod | ทุกตัว guard !IsProduction ในโค้ด ไม่ใช่แค่ config |
| ลบ/แก้ key ใน Redis prod ด้วยมือ | ทำได้เฉพาะ non-prod ระหว่าง POC |
หลักคิดเดียวที่ครอบทุกข้อ: เครื่องมือ debug ต้อง reveal เฉพาะสิ่งที่คนขอเป็นเจ้าของอยู่แล้ว (request ของตัวเอง, canonical form ของ request ตัวเอง) — ไม่ reveal secret ของระบบหรือข้อมูลของคนอื่น