Private Docs

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-Timestampinterceptor ไม่ทำงาน / URL ไม่อยู่ใน signing list (ดู runbook ข้อ 2.1)
SIG_TIMESTAMP_INVALIDtimestamp อ่านไม่ออก / ต่างจาก 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_MALFORMEDsignature ไม่ใช่ base64 / ไม่ใช่ 64 bytes / เป็น DERFE เซ็นผิด 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 เท่านั้น): log string_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

  1. เปิด DevTools → Network → ดู request จริงว่ามี X-Signature/X-Timestamp ไหม
  2. ไม่มี → เช็ค config URL ที่ต้องเซ็น — ⚠️ trap ที่เจอไว้แล้ว: shell ประกาศ config ซ้ำ 2 ที่ (app.config.ts ~บรรทัด 102-108 และ 119) ตัวหลังชนะ — ใส่ค่าผิดที่ interceptor จะเงียบสนิทโดย build เขียวปกติ
  3. มีแต่โดน 401 อยู่ดี → header อาจถูก strip ระหว่างทาง — ยิงตรง service (ข้าม gateway) เทียบ ถ้ายิงตรงผ่านแต่ผ่าน gateway ไม่ผ่าน = ไปดู APIM/CORS (preflight ตก → browser ไม่ส่ง header — เช็ค Cors:AllowedHeaders ของ service)

2.2 SIG_TIMESTAMP_INVALID

  • เทียบนาฬิกาเครื่อง client กับ server (date / response header Date) — 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 ใน consolepreflight — ไม่ใช่ signature ผิด (แต่เช็คตาม 2.1 ข้อ 3)
endpoint อื่นของ UserService พังไม่เกี่ยวกับงานนี้โดยนิยาม — enforcement เป็น opt-in ที่ endpoint ทดสอบเท่านั้น ถ้า endpoint เดิมพังให้ถอน version lib ออกมาดูก่อนเลย

3. เครื่องมือไล่ SIG_INVALID (canonicalization mismatch)

3.1 ลำดับการไล่ — จากถูกสุดไปแพงสุด

  1. รัน fixture ทั้งสองฝั่ง — frozen test vector (แผนข้อ 8) มี jest test (ShareLib pure function) และ .NET test ยึดอยู่ · ฝั่งไหนแดง = ฝั่งนั้นเพิ่งแก้อะไรพัง
  2. เปิด DebugResponse (ข้อ 1.2) + FE debug flag (ข้อ 1.4) → เทียบ canonical parts ทีละช่อง — ช่องแรกที่ไม่ตรงคือคำตอบ
  3. ช่องที่พังบ่อย เรียงตามสถิติของงานประเภทนี้:
ช่องสาเหตุคลาสสิก
bodyHashBOM (server ต้อง hash raw bytes เอง ห้ามใช้ body ที่ middleware cache — contract ระบุแล้ว) · interceptor ตัวอื่น re-serialize body หลังเราเซ็น (ลำดับ interceptor!) · charset/encoding
queryencode ไม่ตรง spec (%20 vs +) · ลำดับ sort · คีย์ซ้ำถูกยุบ
pathtrailing slash · path ผ่าน gateway ถูก rewrite ≠ path ที่ FE เซ็น (เทียบ Network tab กับ debug response)
tsFE เซ็นด้วย 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 ของระบบหรือข้อมูลของคนอื่น